From 37ab41478d1837db835aa55d611c6259cae5195e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:40:54 -0400 Subject: [PATCH] Issue #19: XMP sidecar capture time fills in when the file has none A sidecar's exif:DateTimeOriginal / photoshop:DateCreated / xmp:CreateDate now reaches Photo.captured on import and on Read Metadata from File when the file has no embedded capture time (embedded wins). On import the sidecar is read before copying, so the date also files and names copies. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 39 ++++++++++++++----------- crates/engine/src/sidecar.rs | 17 +++++++++-- crates/engine/src/tests_xmp.rs | 52 ++++++++++++++++++++++++++++++++++ docs/parity.md | 2 +- docs/xmp-interop.md | 5 +++- 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 5b9211d3..bc57fe6d 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -8,8 +8,9 @@ //! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch). //! 5. *Add* in place (the photo points at the original file) or *copy* into the library's //! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy. -//! 6. Read each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for -//! metadata and develop settings are restored (see [`crate::sidecar`]). +//! 6. Apply each photo's XMP sidecar (or a raw/DNG file's embedded XMP): the sidecar wins for +//! metadata and develop settings are restored (see [`crate::sidecar`]). Its capture time fills +//! in when the file has none (read before step 5, so it also files and names the copy). //! 7. Optionally apply a preset and add keywords (the import dialog's options). //! 8. Commit all new photos as one undoable op. //! @@ -553,6 +554,23 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" }); continue; } + // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files + // and names the copy when the file itself has none + let raw = info.kind == MediaKind::Raw; + let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) + .and_then(|f| std::fs::read_to_string(f).ok()) + .or_else(|| info.xmp.clone().filter(|_| raw)); + let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) { + Ok(sc) => (sc != crate::sidecar::SidecarData::default()).then_some(sc), + Err(e) => { + log::warn!("import {path}: XMP: {e}"); + None + } + }); + let mut info = info; + if info.captured.is_none() { + info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); + } let stored = match (mode, ©_root) { (ImportMode::Copy, Some(root)) if !Path::new(&path).starts_with(root) && !lib_dir.as_ref().is_some_and(|l| Path::new(&path).starts_with(l)) => @@ -583,7 +601,6 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c } // Copy as DNG: the copied raw becomes a DNG (the copy is ours to replace) let mut stored = stored; - let mut info = info; if opts.convert_dng && mode == ImportMode::Copy && stored != path && info.kind == MediaKind::Raw && !info.format.eq_ignore_ascii_case("DNG") { match crate::cmd::convert::write_dng_for(s, &stored, String::new()) { Ok(dng) => { @@ -606,19 +623,9 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c p.embedded_lens = info.embedded_lens; p.preview_only = info.preview_only.clone(); apply_import_defaults(s, &mut p); - let raw = p.kind == lightcraft_catalog::MediaKind::Raw; - let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) - .and_then(|f| std::fs::read_to_string(f).ok()) - .or_else(|| info.xmp.clone().filter(|_| raw)); - if let Some(x) = packet { - match crate::sidecar::parse_sidecar(&x, raw).map(|sc| sc.resolve_label(&s.catalog)) { - Ok(sc) if sc != crate::sidecar::SidecarData::default() => { - crate::sidecar::merge_into(&mut p, &sc, &now); - report.sidecars += 1; - } - Ok(_) => {} - Err(e) => log::warn!("import {path}: XMP: {e}"), - } + if let Some(sc) = &sidecar { + crate::sidecar::merge_into(&mut p, sc, &now); + report.sidecars += 1; } if let Some(mp) = opts.metadata_preset.as_ref().and_then(|n| s.metadata_presets.iter().find(|m| m.name.eq_ignore_ascii_case(n))) { crate::cmd::metadata::apply_to(&mut p.meta, &mp.fields); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 714478d3..5deedd3f 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -5,7 +5,9 @@ //! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop //! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`). //! -//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata; develop +//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except +//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`, +//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop //! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:` //! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used. @@ -80,6 +82,9 @@ pub struct SidecarData { pub alt_text: Option, pub extended_description: Option, pub keywords: Option>, + /// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or + /// `xmp:CreateDate` (first found). Used only when the file itself has no capture time. + pub captured: Option, pub develop: Option, } @@ -108,6 +113,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result std::result::Result bool { + if p.captured.is_none() { + p.captured = sc.captured.clone(); + } if let Some(r) = sc.rating { p.rating = r; } @@ -306,6 +316,9 @@ impl Session { Op::SetLabel { id, label: q.label }, Op::SetMeta { id, meta: Box::new(q.meta.clone()) }, ]; + if q.captured != p.captured { + ops.push(Op::SetCaptured { id, captured: q.captured.clone() }); + } if develop_changed { ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File")); } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 2f00a315..c0331ea7 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -445,3 +445,55 @@ fn dng_export_compression_choices() { } assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); } + +fn write_jpeg(path: &Path, capture: Option<&str>) { + let (w, h) = (32usize, 24usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let exif = capture.map(|c| { + lightcraft_meta::write_exif(&lightcraft_meta::Metadata { capture_time: lightcraft_meta::DateTime::parse_iso(c), ..Default::default() }) + }); + let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +const DATED_SIDECAR: &str = r#" + + + +"#; + +#[test] +fn sidecar_capture_time_fills_in_when_the_file_has_none() { + let src = temp_dir("sidecar-date"); + write_jpeg(&src.join("sample.jpg"), None); + std::fs::write(src.join("sample.jpg.xmp"), DATED_SIDECAR).unwrap(); + // the embedded capture time wins over the sidecar's + write_jpeg(&src.join("dated.jpg"), Some("2020-05-06T07:08:09")); + std::fs::write(src.join("dated.jpg.xmp"), DATED_SIDECAR).unwrap(); + let lib = temp_dir("sidecar-date-lib"); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // copy: the sidecar's date also files the copy + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap(); + assert_eq!(r["sidecars"], 2, "{r}"); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().clone(); + let sample = by_name(&s, "sample.jpg"); + assert_eq!(sample.captured.as_deref(), Some("2026-01-14T05:58:48")); + let lightcraft_catalog::Source::File { path } = &sample.source else { panic!("not a file") }; + assert!(Path::new(path).starts_with(lib.join("Originals").join("2026").join("2026-01-14")), "{path}"); + assert_eq!(by_name(&s, "dated.jpg").captured.as_deref(), Some("2020-05-06T07:08:09")); + + // Read Metadata from File fills in a missing capture time too + let id = sample.id; + s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap(); + std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2026-01-14T05:58:48")); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/docs/parity.md b/docs/parity.md index a33caa46..cb4462dd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -194,7 +194,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | βœ… | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings β†’ Import β†’ Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟑 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding | | LR-META-COPYPASTE | Copy / paste metadata | P2 | βœ… | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright, creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | βœ… | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | +| LR-META-XMP | XMP read/write | P0 | βœ… | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | βœ… | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info β–Έ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel β€” global adjustments (EDIT) diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index eea43e2a..f9a68ea9 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -29,7 +29,10 @@ What we write (standard namespaces, so other tools can read the metadata): `lc:` is `http://ns.lightcraft.app/lc/1.0/`. Reading merges into the catalog with the **sidecar winning** for every field it states; fields it doesn't state are -kept. `xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from +kept. The one exception is the **capture time**: a time embedded in the file (EXIF/IPTC) always wins; when the file +has none, the sidecar's `exif:DateTimeOriginal`, else `photoshop:DateCreated`, else `xmp:CreateDate` (ISO 8601) is +used β€” on import (it then also files a copied photo in its date folder) and by `photo.readMetadataFromFile` (undoable). +`xmp:Rating="-1"` (the XMP convention for rejected) sets the reject flag. Develop settings come from `lc:settings` when present (exact); otherwise from the `crs:` fields below (approximate). ## Reading `crs:` develop fields