mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 18:18:12 -06:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a4b53b28cc
commit
0bc2f79bb5
@@ -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))
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<String> = [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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> = (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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmp:Rating="3" crs:HasCrop="True" crs:CropLeft="0.2" crs:CropTop="0.1" crs:CropRight="0.8" crs:CropBottom="0.9" crs:CropAngle="1.5"/></rdf:RDF></x:xmpmeta>"#;
|
||||
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}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- written by another app -->\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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user