# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas
2026-10-14 21:58:01 -04:00
20 changed files with 307 additions and 48 deletions
+115 -18
View File
@@ -162,6 +162,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<Self> {
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 {
@@ -555,6 +578,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.
@@ -607,6 +632,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,
@@ -649,6 +675,7 @@ pub const OPTION_PARAMS: &[&str] = &[
"sharpenAmount",
"naming",
"startNumber",
"destinationMode",
"subfolder",
"conflict",
"tiffCompression",
@@ -839,6 +866,7 @@ impl ExportOptions {
"sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?,
"hdrTransfer" => one_of(k, v, &["pq", "hlg"])?,
"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" => {
@@ -951,6 +979,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() {
@@ -1481,6 +1510,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<String>,
work: Work,
/// The library's originals, which [`run_batch`] never writes over (shared by a batch).
guard: std::sync::Arc<crate::originals::OriginalGuard>,
@@ -1543,6 +1575,7 @@ fn prepare_guarded(
) -> Result<PreparedExport, String> {
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 {
@@ -1589,7 +1622,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<String> {
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)
}
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
@@ -1700,8 +1744,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 {
@@ -1898,12 +1943,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(),
@@ -1918,12 +1963,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>(())
};
@@ -2034,12 +2079,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<String>,
out: Vec<serde_json::Value>,
@@ -2060,29 +2104,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<String, String> {
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<Exported, String>,
) -> 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::<Vec<_>>();
let path = match self.to.exact.as_deref().filter(|_| self.single) {
@@ -3077,6 +3145,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]
+91
View File
@@ -874,3 +874,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<String> = 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);
}