diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f3ddd045e..a8b25023b 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -23,7 +23,8 @@ impl Session { .into_iter() .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; - let mut out = preset.params; + // a preset saved by another version may hold keys this one doesn't know: not the caller's typo + let mut out = crate::export::ExportOptions::known_keys_only(&preset.params); if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { o.insert(k.clone(), v.clone()); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 420d03e61..218baa30b 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -688,6 +688,22 @@ fn whole(v: &serde_json::Value) -> Option { } impl ExportOptions { + /// Stored export settings (a saved preset, `prefs.json ▸ lastExport`) with the keys an older or + /// newer version may have written and this one doesn't know dropped, also inside `watermark`. + /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are + /// not the caller's typo, and refusing them would make a preset or Export with Previous fail + /// for good after an update. + pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); + if let Some(w) = o.get_mut("watermark").and_then(serde_json::Value::as_object_mut) { + w.retain(|k, _| WATERMARK_PARAMS.contains(&k.as_str())); + } + } + p + } + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an /// unknown parameter or a value that cannot be read as what it is for is an error, never a /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3978258fe..7dcd60c1a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -850,3 +850,20 @@ fn selecting_an_unknown_photo_is_an_error_and_keeps_the_selection() { // an empty selection is still allowed assert_eq!(s.execute("library.select", &json!({"ids": []})).unwrap()["selected"], 0); } + +/// A preset saved by another version may hold keys this one doesn't know: exporting with it keeps +/// working (the stale keys are dropped), while a typo the caller makes is still refused (#181). +#[test] +fn saved_presets_with_unknown_keys_still_export() { + use crate::export::{ExportOptions, ExportPreset}; + let mut s = demo(); + s.export_presets.push(ExportPreset { + name: "Old".into(), + params: json!({"format": "png", "retiredOption": 3, "watermark": {"text": "©", "retiredKey": true}}), + }); + let p = s.export_params(&json!({"preset": "old", "quality": 80})).unwrap(); + let o = ExportOptions::from_params(&p).unwrap(); + assert_eq!(o.format, crate::export::ExportFormat::Png); + assert_eq!(o.watermark.map(|w| w.text), Some("©".to_string())); + assert!(ExportOptions::from_params(&s.export_params(&json!({"preset": "old", "qualty": 80})).unwrap()).is_err()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index afff2100a..40e4d0e6a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1240,7 +1240,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option match app.session.last_export.clone() { - Some(prev) => crate::control::export_active(app, &prev), + Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), None => Err("nothing exported yet — use Export…".into()), }, _ => return None,