Import into the library, which is on the server

There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.

An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.

What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.

And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.

FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 21:09:26 +02:00
co-authored by Claude Opus 5
parent ce666c768e
commit d04087af83
8 changed files with 336 additions and 104 deletions
+44 -31
View File
@@ -188,7 +188,9 @@ impl ImportController {
fn can_start(&self) -> bool {
!self.running.get()
&& !self.card.borrow().is_empty()
&& !self.options().destination.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)
}
}
@@ -252,17 +254,9 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
.into(),
);
window.set_import_destination(stored.destination.as_str().into());
// Empty where no account is signed in, or where the user turned the upload
// off — the page then shows one destination because there is one.
window.set_import_upload_target(
if stored.upload {
ctl.upload_target.borrow().clone()
} else {
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());
@@ -333,13 +327,6 @@ where
// 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());
}
}
*ctl.upload_target.borrow_mut() =
context().map(|c| c.library_label).unwrap_or_default();
ctl.refresh_volumes();
@@ -505,16 +492,6 @@ where
}
}
/// 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
@@ -570,14 +547,21 @@ fn start(
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()),
library: PathBuf::from(&stored.destination),
staging: upload.staging.clone(),
backup,
catalog: context.catalog.clone(),
filter: context.filter.clone(),
options: ctl.ingest_options(),
upload: stored.upload.then_some(context.upload).flatten(),
upload,
};
let cancel: import::Cancel = Default::default();
@@ -745,6 +729,35 @@ mod tests {
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