diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..28bb324f0 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -151,6 +151,29 @@ pub enum Conflict { Skip, } +/// Where a batch's files go (Lightroom Classic's Export To). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DestinationMode { + /// One folder for the whole batch ([`Destination::dir`]). + #[default] + SpecificFolder, + /// Each photo's own folder: the folder its original is in. A photo without one on disk (a + /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. + SameAsOriginal, +} + +impl DestinationMode { + /// `specificFolder` / `sameAsOriginal` (any case). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "specificfolder" => Some(Self::SpecificFolder), + "sameasoriginal" => Some(Self::SameAsOriginal), + _ => None, + } + } +} + /// A named set of `app.export` params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportPreset { @@ -645,6 +668,8 @@ pub struct ExportOptions { pub naming: String, /// First `{seq}` value. pub start_number: u32, + /// A specific folder for the whole batch, or each photo's own folder. + pub destination_mode: DestinationMode, /// Created inside the destination folder (empty = none). pub subfolder: String, /// When a file of the same name exists in the destination folder. @@ -695,6 +720,7 @@ impl Default for ExportOptions { sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), start_number: 1, + destination_mode: DestinationMode::SpecificFolder, subfolder: String::new(), conflict: Conflict::Unique, tiff_compression: TiffCompression::Deflate, @@ -736,6 +762,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "sharpenAmount", "naming", "startNumber", + "destinationMode", "subfolder", "conflict", "tiffCompression", @@ -924,6 +951,7 @@ impl ExportOptions { "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { @@ -1036,6 +1064,7 @@ impl ExportOptions { sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), + destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), conflict: enm(p, "conflict").unwrap_or(d.conflict), tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { @@ -1554,6 +1583,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI pub struct PreparedExport { pub photo: lightcraft_catalog::PhotoId, pub file_name: String, + /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` + /// for a demo photo or one in the browser's storage. + pub source_dir: Option, work: Work, /// The library's originals, which [`run_batch`] never writes over (shared by a batch). guard: std::sync::Arc, @@ -1613,6 +1645,7 @@ fn prepare_guarded( ) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; let file_name = o.file_name_for(p, seq); + let source_dir = original_folder(&p.source); // HDR output only for photos edited in HDR; the rest of the batch exports as usual let sdr_opts; let o = if o.hdr && !p.develop.hdr.enabled { @@ -1648,7 +1681,18 @@ fn prepare_guarded( }; (work, weight) }; - Ok(PreparedExport { photo: id, file_name, work, guard, weight }) + Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) +} + +/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo +/// photo, and on the web, where an original lives in the browser's storage, not in a folder. +fn original_folder(source: &lightcraft_catalog::Source) -> Option { + let lightcraft_catalog::Source::File { path } = source else { return None }; + if cfg!(target_arch = "wasm32") { + return None; + } + let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); + (!dir.is_empty()).then_some(dir) } /// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level @@ -1733,8 +1777,9 @@ pub enum DngCompression { } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied to each file together with its sidecars), or one exact file path (single photo; an -/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar) +/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each +/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file +/// already there is replaced). Either way a catalogued original (or its sidecar) /// is never written over: see [`crate::originals`]. #[derive(Clone, Debug, Default)] pub struct Destination { @@ -1931,12 +1976,12 @@ fn run_batch_with( if !progress(i, &item.file_name) { break; } - let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone()); - placer.place(photo, name, &guard, run_gated(item))?; + let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); + placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; } return Ok(std::mem::take(&mut placer.out)); } - let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect(); + let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); let queue = Queue { lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), cv: std::sync::Condvar::new(), @@ -1951,12 +1996,12 @@ fn run_batch_with( } } let placed = || { - for (i, (photo, name, guard)) in heads.into_iter().enumerate() { + for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { if !progress(i, &name) { break; } let e = queue.result(i); - placer.place(photo, name, &guard, e)?; + placer.place(photo, name, source_dir.as_deref(), &guard, e)?; } Ok::<_, String>(()) }; @@ -2067,12 +2112,11 @@ impl Queue { } } -/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an -/// original) and its write, in batch order. +/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, +/// sidecars, never an original) and its write, in batch order. struct Placer<'a> { o: &'a ExportOptions, to: &'a Destination, - dir: String, single: bool, taken: std::collections::HashSet, out: Vec, @@ -2093,29 +2137,53 @@ impl<'a> Placer<'a> { exists: &'a dyn Fn(&str) -> bool, stop_on_error: bool, ) -> Placer<'a> { - let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) }; - Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } + Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } } - /// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err` - /// only when it failed and `stop_on_error` is set. + /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or + /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no + /// folder of its own, or whose folder is gone: never a file written somewhere else instead. + fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result { + let base = match self.o.destination_mode { + DestinationMode::SpecificFolder => self.to.dir.clone(), + DestinationMode::SameAsOriginal => { + let Some(d) = source_dir else { + return Err(format!( + "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" + )); + }; + if !(self.exists)(d) { + return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); + } + d.to_string() + } + }; + Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) + } + + /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it + /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. fn place( &mut self, photo: lightcraft_catalog::PhotoId, name: String, + source_dir: Option<&str>, guard: &crate::originals::OriginalGuard, e: Result, ) -> Result<(), String> { use serde_json::json; - let (o, dir, exists) = (self.o, &self.dir, self.exists); - let e = match e { - Ok(e) => e, + // an exact file path needs no folder + let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; + let (o, exists) = (self.o, self.exists); + let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { + Ok(v) => v, Err(err) if self.stop_on_error => return Err(err), Err(err) => { self.out.push(json!({"photo": photo.0, "file": name, "error": err})); return Ok(()); } }; + let dir = dir.as_str(); // the exported file and its sidecars let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); let path = match self.to.exact.as_deref().filter(|_| self.single) { @@ -3008,6 +3076,35 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own + /// folder; it is checked like the other params and kept in presets and Export with Previous. + #[test] + fn destination_mode_param() { + use serde_json::json; + assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); + for (v, want) in [ + ("sameAsOriginal", DestinationMode::SameAsOriginal), + ("SameAsOriginal", DestinationMode::SameAsOriginal), + ("specificFolder", DestinationMode::SpecificFolder), + ] { + assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); + } + let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); + assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); + assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); + assert!( + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) + .unwrap_err() + .to_string() + .contains("did you mean `destinationMode`") + ); + // saved (a preset, `lastExport`) and read back + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let j = o.to_json(); + assert_eq!(j["destinationMode"], "sameAsOriginal"); + assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); + } + /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. #[test] diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b57bd471b..df304b481 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -859,3 +859,94 @@ mod apple_photos { assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos")); } } + +/// Issue #956: "Same folder as original photo" puts each photo's export next to its own original +/// (plus the subfolder), so one batch from two folders lands in two folders. The conflict policy +/// and the original guard work per folder as they do for a specific folder. +#[test] +fn same_folder_as_original_exports_next_to_each_photo() { + use crate::export::{Conflict, Destination, DestinationMode}; + let dir = temp_dir("export-same-folder"); + let (a, b) = (dir.join("A"), dir.join("B")); + // two different photos (the same bytes would be imported once) + let jpeg = |blue: u8| { + let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, blue, 255]); + crate::export::encode_image(&img, &ExportOptions::default()).unwrap() + }; + let originals = [(&a, jpeg(120)), (&b, jpeg(30))]; + for (d, bytes) in &originals { + std::fs::create_dir_all(d).unwrap(); + std::fs::write(d.join("IMG_1.jpg"), bytes).unwrap(); + } + let mut s = Session::new().with_fs(); + let paths = [a.join("IMG_1.jpg").to_string_lossy().to_string(), b.join("IMG_1.jpg").to_string_lossy().to_string()]; + s.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + assert_eq!(ids.len(), 2); + // the folder of a specific-folder export is not used + let to = Destination { dir: dir.join("unused").to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let exists = |p: &str| std::path::Path::new(p).exists(); + let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); + let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); + let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()]; + let mut got: Vec = written.iter().map(|p| p.to_string()).collect(); + want.sort(); + got.sort(); + assert_eq!(got, want); + assert!(!dir.join("unused").exists(), "nothing written to the specific folder"); + // again: each folder's own collision gets the next free name + let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); + assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("Exports/IMG_1-2.jpg")), "{files:?}"); + // no subfolder: next to the original, which is never written over + let beside = ExportOptions { subfolder: String::new(), conflict: Conflict::Overwrite, ..o.clone() }; + let err = crate::export::export_batch(&mut s, &ids, &beside, &to, &mut crate::export::write_file, &exists).unwrap_err(); + assert!(err.contains("original of IMG_1.jpg"), "{err}"); + let unique = ExportOptions { conflict: Conflict::Unique, ..beside }; + let files = crate::export::export_batch(&mut s, &ids, &unique, &to, &mut crate::export::write_file, &exists).unwrap(); + assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("IMG_1-2.jpg")), "{files:?}"); + for (d, bytes) in &originals { + assert_eq!(&std::fs::read(d.join("IMG_1.jpg")).unwrap(), bytes, "the original is untouched"); + assert!(d.join("IMG_1-2.jpg").exists()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #956: a photo with no folder on disk (a demo photo) or whose folder is gone gets its own +/// error in "Same folder as original photo" mode instead of a file written somewhere else. +#[test] +fn same_folder_as_original_refuses_photos_without_a_folder() { + use crate::export::{Destination, DestinationMode}; + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let to = Destination { dir: "out".into(), exact: None }; + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let mut written = Vec::new(); + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { + written.push(p.to_string()); + Ok(()) + }; + let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); + assert!(err.contains("no folder on disk"), "{err}"); + // without stopping: one error per photo, nothing written + let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); + let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap(); + assert_eq!(files.len(), 2); + for (f, id) in files.iter().zip(&ids) { + assert_eq!(f["photo"], id.0, "{f}"); + assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); + } + drop(write); + assert!(written.is_empty(), "{written:?}"); + + // a photo on disk whose folder is gone (moved, an unplugged drive) + let (mut s, id, dir, _src, _) = library_with_jpeg("export-same-folder-gone"); + let items = crate::export::prepare_batch(&mut s, &[id], &o).unwrap(); + let gone = dir.to_string_lossy().to_string(); + let exists = |p: &str| p != gone && !p.starts_with(&format!("{gone}/")); + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { Err(format!("wrote {p}")) }; + let files = crate::export::run_batch(items, &o, &to, &mut write, &exists, false, &mut |_, _| true).unwrap(); + let e = files[0]["error"].as_str().unwrap(); + assert!(e.contains("folder is missing") && e.contains(&gone), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..9a3de29c4 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -142,7 +142,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..4453d07a2 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -235,7 +235,8 @@ pub fn helper_tools(has_ui: bool) -> Vec { "preset": {"type": "string", "description": "Export preset id or name"}, "background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"}, "resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"}, - "subfolder": {"type": "string"}, + "destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"}, + "subfolder": {"type": "string", "description": "Created inside the destination folder"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, @@ -746,6 +747,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "preset", "background", "resize", + "destinationMode", "subfolder", "conflict", "tiffCompression", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..434fe4176 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,8 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Export to": "Exportieren nach", + "Specific folder": "Bestimmter Ordner", + "Same folder as original photo": "Im selben Ordner wie das Originalfoto" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..af07a801a 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Export to": "Exportar a", + "Specific folder": "Carpeta específica", + "Same folder as original photo": "Misma carpeta que la foto original" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..329322ac3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Export to": "Exporter vers", + "Specific folder": "Dossier spécifique", + "Same folder as original photo": "Même dossier que la photo d’origine" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..f3cfd2078 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,8 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Export to": "書き出し先", + "Specific folder": "指定フォルダー", + "Same folder as original photo": "元の写真と同じフォルダー" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..9c99a14dd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Export to": "Exportar para", + "Specific folder": "Pasta específica", + "Same folder as original photo": "Mesma pasta da foto original" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..ea0970e30 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,8 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Export to": "Экспорт в", + "Specific folder": "Указанная папка", + "Same folder as original photo": "Та же папка, что и у оригинала" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..0978bcbb7 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,8 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Export to": "Експорт до", + "Specific folder": "Вказана папка", + "Same folder as original photo": "Та сама папка, що й оригінал" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..cb39d88b9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,8 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Export to": "导出到", + "Specific folder": "指定文件夹", + "Same folder as original photo": "与原始照片相同的文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..ec4f99849 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Export to": "輸出至", + "Specific folder": "指定資料夾", + "Same folder as original photo": "與原始照片相同的資料夾" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..017a48e5f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -480,7 +480,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result d.trim().to_string(), @@ -490,7 +491,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-EXP-LOCATION: Same folder as original photo; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -395,7 +395,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-EXP-LOCATION: Export to "Same folder as original photo" (`destinationMode`), issue #956; status unchanged (✅) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..d9ddf49ce 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,6 +11,16 @@ - Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in noticeably duller. +### Export next to the originals +- The Export dialog has Export to: Specific folder (as before) or Same folder as original photo, like Lightroom + Classic (issue #956). With the second, each photo is exported into the folder its original is in, plus the + Subfolder if you give one (say `Exports`), so a batch from several folders lands next to each set of originals. + "If file exists" works per folder as usual, and an original is never written over. +- A photo that has no folder on disk (a demo photo, or one in the web version's browser storage) or whose folder + is missing (moved, drive unplugged) is not exported; it gets its own error naming it. +- The choice is saved in export presets and Export with Previous. Agents pass + `destinationMode: "sameAsOriginal"` (or `"specificFolder"`) to `app.export`; `dir` is then not needed. + ### Open Library knows a Lightroom catalog - Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` / `.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The