rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused.
875 lines
30 KiB
Rust
875 lines
30 KiB
Rust
//! TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b
|
|
//! The import page: state, and the callbacks that move it.
|
|
//!
|
|
//! Split from [`crate::import`] the way `library_ui` is split from `library`:
|
|
//! that module knows how to bring a card in and nothing about windows, this one
|
|
//! knows about windows and nothing about copying.
|
|
//!
|
|
//! # `render` is the only writer of window properties
|
|
//!
|
|
//! Every callback changes the controller and then calls [`render`], rather than
|
|
//! setting the one property it touched. The settings page works the same way
|
|
//! and for the same reason: several of these properties are derived from
|
|
//! others — whether Import can be pressed depends on the source, the
|
|
//! destination and whether a run is going — and a callback that updated only
|
|
//! its own field would leave the derived ones describing a state that has
|
|
//! passed.
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
|
|
use slint::ComponentHandle;
|
|
|
|
use dr_ingest::{DuplicatePolicy, Options, TransferMode, DEFAULT_TEMPLATE};
|
|
use dr_types::{FormatFilter, ImportSettings};
|
|
|
|
use crate::activity::{Activity, ActivityLog, Kind};
|
|
use crate::import::{self, Message, Request, Upload};
|
|
use crate::{AppWindow, Page};
|
|
|
|
/// 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.
|
|
///
|
|
/// Shown on the page, because once an account is signed in "where do these
|
|
/// end up" has two answers, and giving only the local one answers half the
|
|
/// question the Import button is about to commit to.
|
|
pub library_label: String,
|
|
/// Which file types to take off a card.
|
|
pub filter: FormatFilter,
|
|
}
|
|
|
|
/// Everything the import page renders from.
|
|
pub struct ImportController {
|
|
/// Persisted defaults, shared with the settings page so both write one
|
|
/// file. An import's options are remembered between cards.
|
|
settings: Rc<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>,
|
|
|
|
/// The server folder originals are uploaded to, as the page shows it.
|
|
///
|
|
/// Cached when the page opens rather than fetched in `render`: the context
|
|
/// closure reaches the account, and `render` runs on every keystroke.
|
|
upload_target: RefCell<String>,
|
|
|
|
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),
|
|
upload_target: RefCell::new(String::new()),
|
|
running: Cell::new(false),
|
|
fraction: Cell::new(0.0),
|
|
status: RefCell::new(String::new()),
|
|
error: RefCell::new(String::new()),
|
|
cancel: RefCell::new(None),
|
|
timer: RefCell::new(None),
|
|
job: RefCell::new(None),
|
|
})
|
|
}
|
|
|
|
/// The import section of the stored settings.
|
|
fn options(&self) -> ImportSettings {
|
|
self.settings.settings.borrow().import.clone()
|
|
}
|
|
|
|
/// Change a stored option and write the file.
|
|
fn edit(&self, f: impl FnOnce(&mut ImportSettings)) {
|
|
let mut settings = self.settings.settings.borrow_mut();
|
|
f(&mut settings.import);
|
|
let to_save = settings.clone();
|
|
drop(settings);
|
|
if let Err(e) = self.settings.store.save(&to_save) {
|
|
*self.error.borrow_mut() = format!("could not save the import settings: {e}");
|
|
}
|
|
}
|
|
|
|
/// Look for cards. Cheap, and safe to call whenever the page is shown.
|
|
fn refresh_volumes(&self) {
|
|
let found = dr_plat::volumes();
|
|
// Keep a typed path: a refresh must not discard what the user entered
|
|
// because the app happened to find three other volumes.
|
|
if self.card.borrow().is_empty() {
|
|
if let Some(first) = found.iter().position(|v| v.is_likely_card()) {
|
|
self.selected.set(first as i32);
|
|
*self.card.borrow_mut() = found[first].path.display().to_string();
|
|
}
|
|
} else {
|
|
// Re-point the selection at whichever volume the path belongs to,
|
|
// so a remembered path still highlights its row.
|
|
let card = self.card.borrow().clone();
|
|
self.selected.set(
|
|
found
|
|
.iter()
|
|
.position(|v| v.path.display().to_string() == card)
|
|
.map(|i| i as i32)
|
|
.unwrap_or(-1),
|
|
);
|
|
}
|
|
*self.volumes.borrow_mut() = found;
|
|
}
|
|
|
|
/// The transfer options a run is started with.
|
|
fn ingest_options(&self) -> Options {
|
|
let stored = self.options();
|
|
Options {
|
|
folder_template: template_of(&stored),
|
|
mode: if stored.move_from_card {
|
|
TransferMode::Move
|
|
} else {
|
|
TransferMode::Copy
|
|
},
|
|
on_duplicate: if stored.skip_duplicates {
|
|
DuplicatePolicy::Skip
|
|
} else {
|
|
DuplicatePolicy::ImportAsNew
|
|
},
|
|
verify: stored.verify,
|
|
}
|
|
}
|
|
|
|
/// Whether Import can be pressed.
|
|
///
|
|
/// Three things have to be true and all three are recoverable by the user,
|
|
/// which is why the button is disabled rather than the press being refused
|
|
/// with a message: a disabled button beside a filled-in form says which
|
|
/// field is missing, where a dialogue says it after the fact.
|
|
fn can_start(&self) -> bool {
|
|
!self.running.get()
|
|
&& !self.card.borrow().is_empty()
|
|
// An account, because the library is on the server and an import
|
|
// with none has nowhere to go at all.
|
|
&& !self.upload_target.borrow().is_empty()
|
|
&& self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
/// The template a run uses: the stored one, or the layout's default.
|
|
///
|
|
/// Empty means "the default" rather than "no folders" — see `ImportSettings`.
|
|
fn template_of(stored: &ImportSettings) -> String {
|
|
if stored.folder_template.trim().is_empty() {
|
|
DEFAULT_TEMPLATE.to_string()
|
|
} else {
|
|
stored.folder_template.clone()
|
|
}
|
|
}
|
|
|
|
/// What the template does, shown against a real date.
|
|
///
|
|
/// Uses *today* rather than a fixed example: a photographer importing today's
|
|
/// shoot recognises today's folder, and a preview showing 2026-08-22 forever
|
|
/// reads as a placeholder nobody has to check.
|
|
fn preview(stored: &ImportSettings, now: i64) -> String {
|
|
let shot = dr_ingest::Shot {
|
|
captured_at: Some(now),
|
|
make: Some("Canon".into()),
|
|
model: Some("EOS R5".into()),
|
|
..Default::default()
|
|
};
|
|
dr_ingest::expand(&template_of(stored), &shot).display()
|
|
}
|
|
|
|
/// Push the whole controller into the window.
|
|
pub fn render(window: &AppWindow, ctl: &Rc<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(),
|
|
);
|
|
|
|
// The only destination there is. Empty means no account, which is what
|
|
// keeps Import disabled — see `can_start`.
|
|
window.set_import_upload_target(ctl.upload_target.borrow().as_str().into());
|
|
window.set_import_folder_template(stored.folder_template.as_str().into());
|
|
window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into());
|
|
|
|
window.set_import_mode_labels(slint::ModelRc::new(slint::VecModel::from(vec![
|
|
slint::SharedString::from("Copy"),
|
|
slint::SharedString::from("Move"),
|
|
])));
|
|
window.set_import_mode_selected(if stored.move_from_card { 1 } else { 0 });
|
|
window.set_import_mode_is_move(stored.move_from_card);
|
|
|
|
window.set_import_duplicate_labels(slint::ModelRc::new(slint::VecModel::from(vec![
|
|
slint::SharedString::from("Skip"),
|
|
slint::SharedString::from("Import again"),
|
|
])));
|
|
window.set_import_duplicate_selected(if stored.skip_duplicates { 0 } else { 1 });
|
|
|
|
window.set_import_verify(stored.verify);
|
|
window.set_import_backup_on(!stored.backup.is_empty());
|
|
window.set_import_backup_path(stored.backup.as_str().into());
|
|
|
|
window.set_import_running(ctl.running.get());
|
|
window.set_import_fraction(ctl.fraction.get());
|
|
window.set_import_status(ctl.status.borrow().as_str().into());
|
|
window.set_import_error(ctl.error.borrow().as_str().into());
|
|
window.set_import_can_start(ctl.can_start());
|
|
}
|
|
|
|
/// Why a volume is being offered.
|
|
fn describe_volume(v: &dr_plat::Volume) -> String {
|
|
let what = if v.has_dcim {
|
|
"camera card"
|
|
} else if v.removable {
|
|
"removable volume"
|
|
} else {
|
|
"volume"
|
|
};
|
|
format!("{what} · {}", v.path.display())
|
|
}
|
|
|
|
/// Connect the page's callbacks.
|
|
///
|
|
/// `context` is asked for afresh on every run rather than captured, because the
|
|
/// account and library it describes can change while the page is open.
|
|
/// `on_imported` runs after a successful import so the caller can rescan — this
|
|
/// module does not catalogue what it wrote (see [`crate::import`]).
|
|
pub fn wire<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);
|
|
|
|
wire_opening_and_closing(window, &ctl, &context);
|
|
wire_choosing_a_source(window, &ctl, &context);
|
|
wire_options(window, &ctl);
|
|
wire_running(window, &ctl, &context, &on_imported);
|
|
}
|
|
|
|
/// Opening and closing.
|
|
fn wire_opening_and_closing<C>(window: &AppWindow, ctl: &Rc<ImportController>, context: &Rc<C>)
|
|
where
|
|
C: Fn() -> Option<Context> + 'static,
|
|
{
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let context = context.clone();
|
|
window.on_import_open(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
// The button that reaches here is hidden where this is false, so
|
|
// arriving anyway means a key binding or a stale window state.
|
|
// Refused rather than shown empty.
|
|
if !dr_plat::imports_supported() {
|
|
return;
|
|
}
|
|
// Re-read the settings file for the same reason the settings page
|
|
// does: another instance may have written it since.
|
|
*ctl.settings.settings.borrow_mut() = ctl.settings.store.load();
|
|
|
|
*ctl.upload_target.borrow_mut() =
|
|
context().map(|c| c.library_label).unwrap_or_default();
|
|
ctl.refresh_volumes();
|
|
*ctl.status.borrow_mut() = String::new();
|
|
*ctl.error.borrow_mut() = String::new();
|
|
survey(&w, &ctl, &context);
|
|
render(&w, &ctl);
|
|
w.set_active_page(Page::Import);
|
|
});
|
|
}
|
|
|
|
{
|
|
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_active_page(Page::None);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Choosing a source.
|
|
fn wire_choosing_a_source<C>(window: &AppWindow, ctl: &Rc<ImportController>, context: &Rc<C>)
|
|
where
|
|
C: Fn() -> Option<Context> + 'static,
|
|
{
|
|
{
|
|
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 };
|
|
set_card(&w, &ctl, &context, text.to_string());
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let context = context.clone();
|
|
window.on_import_choose_card(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let start = ctl.card.borrow().clone();
|
|
let (weak, ctl, context) = (weak.clone(), ctl.clone(), context.clone());
|
|
crate::folder_dialog::ask(
|
|
&w,
|
|
"Import from",
|
|
crate::folder_dialog::Pick::Folder,
|
|
Some(&start),
|
|
move |path| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
set_card(&w, &ctl, &context, path.to_string_lossy().into_owned());
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
{
|
|
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.
|
|
fn wire_options(window: &AppWindow, ctl: &Rc<ImportController>) {
|
|
{
|
|
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);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
window.on_import_choose_backup(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let start = ctl
|
|
.settings
|
|
.settings
|
|
.borrow()
|
|
.import
|
|
.backup
|
|
.trim()
|
|
.to_string();
|
|
let (weak, ctl) = (weak.clone(), ctl.clone());
|
|
crate::folder_dialog::ask(
|
|
&w,
|
|
"Second copy in",
|
|
crate::folder_dialog::Pick::Folder,
|
|
Some(&start),
|
|
move |path| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
ctl.edit(|s| s.backup = path.to_string_lossy().into_owned());
|
|
render(&w, &ctl);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Running.
|
|
fn wire_running<C, F>(
|
|
window: &AppWindow,
|
|
ctl: &Rc<ImportController>,
|
|
context: &Rc<C>,
|
|
on_imported: &Rc<F>,
|
|
) where
|
|
C: Fn() -> Option<Context> + 'static,
|
|
F: Fn() + 'static,
|
|
{
|
|
{
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Take a source the user named — typed, or chosen in the dialogue — rather
|
|
/// than one from the volume list, and count what is on it.
|
|
fn set_card(
|
|
w: &AppWindow,
|
|
ctl: &Rc<ImportController>,
|
|
context: &Rc<impl Fn() -> Option<Context>>,
|
|
path: String,
|
|
) {
|
|
*ctl.card.borrow_mut() = path;
|
|
ctl.selected.set(-1);
|
|
survey(w, ctl, context);
|
|
render(w, ctl);
|
|
}
|
|
|
|
/// Count what is on the card, without copying anything.
|
|
///
|
|
/// Runs on the UI thread, which is defensible only because it is a directory
|
|
/// walk with no reads: a full card is a couple of thousand `stat` calls. If a
|
|
/// slow reader ever makes this visible, it moves to the worker beside the
|
|
/// import — the message type already has a `Surveyed` variant for it.
|
|
fn survey(
|
|
window: &AppWindow,
|
|
ctl: &Rc<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 = if stored.backup.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(PathBuf::from(stored.backup.trim()))
|
|
};
|
|
|
|
let Some(upload) = context.upload else {
|
|
*ctl.error.borrow_mut() =
|
|
"Sign in first — the library this imports into is on the server.".into();
|
|
render(window, ctl);
|
|
return;
|
|
};
|
|
|
|
let request = Request {
|
|
card: PathBuf::from(ctl.card.borrow().clone()),
|
|
staging: upload.staging.clone(),
|
|
backup,
|
|
catalog: context.catalog.clone(),
|
|
filter: context.filter.clone(),
|
|
options: ctl.ingest_options(),
|
|
upload,
|
|
decoder: dr_decode::default(),
|
|
};
|
|
|
|
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 an_import_needs_an_account_before_it_can_start() {
|
|
// The library is on the server, so "where does this go" has no local
|
|
// answer to fall back on. Import stays disabled rather than quietly
|
|
// filling a folder nothing ever shows.
|
|
let ctl = ImportController::new(
|
|
crate::settings_ui::SettingsController::new(),
|
|
crate::activity::ActivityLog::new(),
|
|
);
|
|
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
|
|
*ctl.survey.borrow_mut() = Some((12, 1234));
|
|
assert!(!ctl.can_start(), "no account, yet Import was offered");
|
|
|
|
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
|
|
assert!(ctl.can_start());
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_card_cannot_be_imported_either() {
|
|
let ctl = ImportController::new(
|
|
crate::settings_ui::SettingsController::new(),
|
|
crate::activity::ActivityLog::new(),
|
|
);
|
|
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
|
|
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
|
|
*ctl.survey.borrow_mut() = Some((0, 0));
|
|
assert!(!ctl.can_start());
|
|
}
|
|
|
|
#[test]
|
|
fn a_volume_says_why_it_is_being_offered() {
|
|
// "EOS DIGITAL" and "archive" look equally plausible in a list, and
|
|
// only one of them is what was just plugged in.
|
|
let card = dr_plat::Volume {
|
|
label: "EOS DIGITAL".into(),
|
|
path: "/run/media/duncan/EOS DIGITAL".into(),
|
|
removable: true,
|
|
has_dcim: true,
|
|
};
|
|
assert!(describe_volume(&card).starts_with("camera card · "));
|
|
|
|
let drive = dr_plat::Volume {
|
|
has_dcim: false,
|
|
..card.clone()
|
|
};
|
|
assert!(describe_volume(&drive).starts_with("removable volume · "));
|
|
|
|
let fixed = dr_plat::Volume {
|
|
removable: false,
|
|
has_dcim: false,
|
|
..card
|
|
};
|
|
assert!(describe_volume(&fixed).starts_with("volume · "));
|
|
}
|
|
}
|