From 0bc2f79bb5d41e6103a68ee3f986e7791994a036 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Wed, 14 Oct 2026 18:56:21 +1100 Subject: [PATCH] Import: find sidecars whose photo extension or .xmp differs in case On a case-sensitive file system, `DSC_1.nef.xmp` or `DSC_1.Xmp` beside `DSC_1.NEF` was never found, so the photo came in with only the file's own metadata (its IPTC and keywords) and lost the sidecar's rating and crop. The lookup now tries the photo extension in lower and upper case and `.xmp`/`.XMP`/`.Xmp`; Move takes such a sidecar along. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/import_move.rs | 5 +++- crates/engine/src/sidecar.rs | 16 ++++++++--- crates/engine/src/tests_import.rs | 48 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index c92ee2796..06f22be40 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -128,7 +128,10 @@ pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> { if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() { return None; } - if base == name { + // `IMG_1.cr3.xmp` names `IMG_1.CR3` too: some apps lower-case the photo's extension + let full = base == name + || base.rsplit_once('.').is_some_and(|(s, x)| s == stem && name.rsplit_once('.').is_some_and(|(_, y)| x.eq_ignore_ascii_case(y))); + if full { Some((e.path(), false)) } else if base == stem { Some((e.path(), true)) diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..fc49fc7cb 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -63,16 +63,24 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf { } /// An existing sidecar for `original`: the preferred naming first, then the other one (also -/// accepting an upper-case `.XMP`). +/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as +/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss). pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; + let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let base = Path::new(original).with_extension(""); + let recased = |e: String| (e != ext).then(|| format!("{}.{e}", base.to_string_lossy())); + let full_variants: Vec = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect(); [naming, other] .into_iter() .flat_map(|n| { - let p = sidecar_path(original, n); - let upper = p.with_extension("XMP"); - [p, upper] + let mut v = vec![sidecar_path(original, n)]; + if n == SidecarNaming::Full { + v.extend(full_variants.iter().map(|o| sidecar_path(o, n))); + } + v }) + .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) .find(|p| p.is_file()) } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..d49426039 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1612,3 +1612,51 @@ fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup } let _ = std::fs::remove_dir_all(&src); } +/// Sidecars are found and read whatever their name's case, header or byte-order mark, so a +/// raw's rating and crop never silently stay behind while the file's own IPTC comes in (fork #43). +#[test] +fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec = (0..60 * 40).flat_map(|i| [(i % 251) as u8, 90, 200]).collect(); + let j = encode_jpeg(&EncodeImage::new(60, 40, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut nef = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, 38u32), (514, j.len() as u32)] { + nef.extend_from_slice(&tag.to_le_bytes()); + nef.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + nef.extend_from_slice(&v.to_le_bytes()); + } + nef.extend_from_slice(&[0, 0, 0, 0]); + nef.extend_from_slice(&j); + let body = r#""#; + let cases = [ + ("darktable-full", "DSC_0001.NEF.xmp", body.to_string()), + ("lower-full", "DSC_0001.nef.xmp", body.to_string()), + ("upper-full", "DSC_0001.NEF.XMP", body.to_string()), + ("mixed-stem", "DSC_0001.Xmp", body.to_string()), + ("bom-and-decl", "DSC_0001.xmp", format!("\u{feff}\n\n{body}")), + ]; + for (name, file, text) in cases { + let src = temp_dir(&format!("sc-variant-{name}")); + std::fs::write(src.join("DSC_0001.NEF"), &nef).unwrap(); + std::fs::write(src.join(file), text).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), r["sidecars"].as_u64()), (1, Some(1)), "{name}: {r}"); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.rating, 3, "{name}"); + let c = p.develop.to_json()["crop"]["geometry"].clone(); + assert!((c["rect"]["x0"].as_f64().unwrap_or(0.0) - 0.2).abs() < 1e-6, "{name}: {c}"); + if name == "lower-full" { + // a move takes that sidecar along + let dest = temp_dir("sc-variant-dest"); + let mut s = Session::new().with_fs(); + let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(), "organize": "flat"}); + let r = s.execute("library.import", &args).unwrap(); + assert_eq!(s.catalog.photos().next().unwrap().rating, 3, "{r}"); + assert!(std::fs::read_dir(&dest).unwrap().flatten().any(|e| e.file_name().to_string_lossy().to_lowercase().ends_with(".xmp")), "{r}"); + assert!(!src.join(file).exists(), "no sidecar left behind: {r}"); + let _ = std::fs::remove_dir_all(&dest); + } + let _ = std::fs::remove_dir_all(&src); + } +}