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:
+44
-31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user