Merge pull request #238 from DWAA1660/fix/export-missing-folder

Export: clear error when there's no export folder (instead of "Access is denied")
This commit is contained in:
Brandon Thomas 2026-10-07 20:09:19 -04:00 committed by GitHub
commit be87e1b6e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 54 additions and 12 deletions

View File

@ -334,9 +334,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
}
}
/// Default export folder: `~/Pictures/LightCraft Exports` (falls back to the working directory).
/// Shown when an export has nowhere to go (no folder typed or chosen, and no home folder to default to).
pub const NO_EXPORT_FOLDER: &str = "Choose an export folder first.";
/// Default export folder: `~/Pictures/LightCraft Exports` (`%USERPROFILE%\Pictures\LightCraft Exports`
/// on Windows, where `HOME` usually isn't set). Empty when no home folder is known.
pub fn default_export_dir() -> String {
std::env::var("HOME").map(|h| format!("{h}/Pictures/LightCraft Exports")).unwrap_or_default()
let home = if cfg!(windows) { std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) } else { std::env::var_os("HOME") };
home.filter(|h| !h.is_empty())
.map(|h| std::path::PathBuf::from(h).join("Pictures").join("LightCraft Exports").to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Export the selected photos (UI command `app.export`). Params: see
@ -365,10 +372,23 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
if ids.is_empty() {
return Err("no photo selected".into());
}
// no folder given (e.g. File → Export with Preset): the last export's, else the default
let last_dir = app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::to_string);
let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).or(last_dir).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir);
let to = Destination { dir: dir.clone(), exact: p.get("path").and_then(Value::as_str).map(str::to_string) };
// An exact output file needs no folder. A folder given but left blank (the Export dialog's Folder
// field cleared) is an error, like Lightroom refusing to export to an unspecified folder, rather
// than a silent write into the working directory. No folder at all (e.g. File → Export with
// Preset): the last export's, else the default.
let exact = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string);
let dir = match p.get("dir").and_then(Value::as_str) {
Some(d) => d.trim().to_string(),
None => {
let last_dir =
app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::trim).filter(|d| !d.is_empty());
last_dir.map(str::to_string).unwrap_or_else(default_export_dir)
}
};
if dir.is_empty() && exact.is_none() {
return Err(NO_EXPORT_FOLDER.into());
}
let to = Destination { dir: dir.clone(), exact };
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false) && app.services.write_shared.is_some();
let out = if background {
let items = lightcraft_engine::export::prepare_batch(&mut app.session, &ids, &opts)?;

View File

@ -753,7 +753,13 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
});
}
if let Some((id, params)) = clicked {
let _ = run_item(app, &id, params);
let r = run_item(app, &id, params);
// an export that can't start (e.g. no folder) says why instead of doing nothing
if let Err(e) = r
&& matches!(id.as_str(), "app.export" | "app.exportPrevious")
{
app.toast(ui.ctx(), e);
}
}
ui.cursor().left() - start
}
@ -999,6 +1005,13 @@ mod tests {
// remembered (expanded) for Export with Previous, folder included
let last = app.session.last_export.clone().unwrap();
assert_eq!((last["format"].as_str(), last["width"].as_u64(), last.get("preset")), (Some("png"), Some(40), None));
// a blank folder (the Export dialog's Folder field cleared) is refused with a clear message
// instead of writing into the working directory
let n = w.len();
drop(w);
let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "}));
assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER);
assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder");
}
#[test]

View File

@ -824,8 +824,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
if confirm {
match confirm_dialog(app, &dlg) {
// the import review stays open on an error (e.g. an unusable folder template)
Err(e) if matches!(dlg, Dialog::Import { .. }) => app.toast(ctx, e),
// the import review stays open on an error (e.g. an unusable folder template), and so
// does Export (e.g. no folder chosen) so the choices aren't lost
Err(e) if matches!(dlg, Dialog::Import { .. } | Dialog::Export { .. }) => app.toast(ctx, e),
// the SAM 3 dialog stays open to show the download (or why it can't start)
Err(e) if matches!(dlg, Dialog::SamModel { .. }) => {
if let Dialog::SamModel { error, .. } = &mut dlg {

View File

@ -158,8 +158,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
o.remove("advance");
}
cull(app, id, params, advance);
} else {
let _ = app.run(id, params);
} else if let Err(e) = app.run(id, params)
&& matches!(id, "app.export" | "app.exportPrevious")
{
// an export that can't start (e.g. no folder) says why instead of doing nothing
app.toast(ctx, e);
}
}
for f in fire {
@ -255,7 +258,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
continue;
}
}
let _ = app.run(&f, json!({}));
// an export that can't start (e.g. no folder) says why instead of doing nothing
if let Err(e) = app.run(&f, json!({}))
&& matches!(f.as_str(), "app.export" | "app.exportPrevious")
{
app.toast(ctx, e);
}
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),