app.slint carried show-launch, show-library, show-identity, show-settings,
show-import and show-merge as separate booleans, so the root component chose
what to draw with five- and six-term conjunctions and nothing stopped two of
them being true at once. Replaced with two enums: View { develop, library,
identity, launch } for which top-level screen is showing, and Page { none,
settings, import, merge } for which page, if any, is drawn over it.
Two values rather than one, because the two questions are genuinely
different. Settings, Import and Merge are reachable from more than one View
and are drawn outermost without touching it — closing one has to return to
whichever View was already current, and today that works because the
underlying property is left alone while the page sits over it. A single
View with five or more variants would need a second field remembering what
to return to; Page needs nothing to remember, since View was never
overwritten in the first place. Identity, by contrast, genuinely replaces
the window the way Launch and Library do (see the existing "like the launch
screen" comment on its `if`), so it is a View variant, not a Page.
Every `if` chain in app.slint that used to compare four, five or six
booleans now compares active-view and active-page to at most one variant
each. library-visible collapsed from a six-term conjunction to
`active-page == Page.none && active-view == View.library`.
The Rust side follows: every set_show_*/get_show_* call in library_ui.rs,
identity_ui.rs, settings_ui.rs, merge_ui.rs, import_ui.rs, launch_ui.rs and
lib.rs now reads or writes active-view or active-page instead, including
lib.rs's startup match (View.launch vs View.develop, since a Startup that
skips the launch screen used to leave both old booleans false and fall
through the chain to develop) and identity_ui's close handler, which now
writes View.library or View.develop in one call where it used to write
show-library then show-identity separately.
back_one_step needed one deliberate adjustment beyond the mechanical
rename. Identity was never represented in NavState: back had nothing to do
when Identity was opened from the library (show-library stayed true,
unread by IdentityScreen's own condition) and could only reach ToLibrary
when opened from develop, which likewise wrote a property IdentityScreen
never read — so escaping out of Identity was invisible in both cases before
this change. With a single active-view, falling into the general case
would instead overwrite the value IdentityScreen's `if` does read and close
it as an unintended side effect. back_one_step now swallows the gesture
while View.identity is current, reproducing the same "nothing visible
happens" outcome for both origins without threading identity_ui's private
came-from-library state through lib.rs for one screen.
Verified with tools/manual/drive.py against a private Xvfb and the debug
build: launch screen to library, Settings opened and closed, Identity
opened and closed (including Escape doing nothing while it is open),
develop opened from a cell and closed both by the back button and by
Escape. Screenshots under verify/.
814 lines
28 KiB
Rust
814 lines
28 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 };
|
|
*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.
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
};
|
|
|
|
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 · "));
|
|
}
|
|
}
|