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); + } +}