From 647e6137fe4142a2dc2595eb33bdb8b9922980f3 Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:03:31 +0000 Subject: [PATCH 1/2] CI: fix current lint and empty-state glyph failures --- apps/lightcraft/src/native_menu.rs | 4 +--- crates/ui-egui/src/panels/compare.rs | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index a5a14e208..0a14918a8 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -489,9 +489,7 @@ impl NativeMenu { // events never reach `shortcuts::handle`, so this is the one guard for AppKit's // accelerators and clicks: while the blocking library window or a quit/settings notice // owns input, a queued command is dropped (Quit stays for the existing confirmation). - let (cmd, params) = if key == QUIT { - (key, serde_json::json!({})) - } else if key == "app.about" || key == SETTINGS { + let (cmd, params) = if key == QUIT || key == "app.about" || key == SETTINGS { (key, serde_json::json!({})) } else if let Some(it) = self.items.get(&key) { (it.cmd.clone(), it.params.clone()) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 829500da4..e6da819e8 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -601,7 +601,12 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) { let canvas = area_and_filmstrip(app, ui); let reference = app.ui.reference.map(PhotoId).filter(|r| live(app, *r)); let (Some(r), Some(active)) = (reference, app.session.active()) else { - super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)"); + super::empty_message( + ui, + canvas, + "No reference photo", + "Right-click a photo and choose Set as Reference Photo, then View → Reference View (Shift+R)", + ); return; }; let area = canvas.shrink(18.0); From a1e1b4cc0e37a8016b933eba099460ed99b00b4c Mon Sep 17 00:00:00 2001 From: ronibalthazar <61232335+ronibalthazar@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:03:33 +0000 Subject: [PATCH 2/2] LR-META-EXIF-FULL: reject malformed EXIF clocks --- crates/engine/src/tests_import.rs | 128 ++++++++++++++++++++++++++++++ crates/meta/README.md | 4 + crates/meta/src/datetime.rs | 46 +++++++++-- crates/meta/src/exif.rs | 47 +++++++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 4 +- docs/whats-new.md | 7 ++ docs/xmp-interop.md | 8 +- 8 files changed, 236 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 6e5585a82..e24f364eb 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -26,6 +26,34 @@ fn write_png(path: &Path, seed: u8) { std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } +fn exif_clock_block(original: Option<&str>, digitized: Option<&str>) -> Vec { + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + + let mut exif = IfdBuilder::new(); + if let Some(value) = original { + exif.set(0x9003, Value::Ascii(value.into())); + } + if let Some(value) = digitized { + exif.set(0x9004, Value::Ascii(value.into())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set_child(0x8769, exif); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() +} + +fn write_png_with_metadata(path: &Path, seed: u8, exif: Option<&[u8]>, xmp: Option<&str>) { + let (w, h) = (48usize, 32usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let metadata = lightcraft_codecs::EncodeMeta { exif, xmp, ..Default::default() }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &metadata).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + // Keep the same known file-date fallback as write_png, distinct from the regression timestamp. + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); +} + fn ids(v: &Value, key: &str) -> usize { v[key].as_array().map(Vec::len).unwrap_or(0) } @@ -124,6 +152,106 @@ fn civil_dates() { assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20"); } +#[test] +fn exif_clock_import_uses_valid_fallback_dates_and_keeps_raw_tags() { + const INVALID: &str = "2026:01:01 12:34:56:99"; + const XMP: &str = r#""#; + let dir = temp_dir("exif-clock-fallbacks"); + let digitized_path = dir.join("digitized.png"); + let digitized_exif = exif_clock_block(Some(INVALID), Some("2026:01:02 10:00:00")); + write_png_with_metadata(&digitized_path, 1, Some(&digitized_exif), None); + let xmp_path = dir.join("xmp.png"); + let malformed_only_exif = exif_clock_block(Some(INVALID), None); + write_png_with_metadata(&xmp_path, 2, Some(&malformed_only_exif), Some(XMP)); + + let mut s = Session::new().with_fs(); + let imported = s.execute("library.import", &json!({"paths": [digitized_path.to_string_lossy(), xmp_path.to_string_lossy()]})).unwrap(); + assert_eq!(ids(&imported, "imported"), 2, "{imported}"); + let digitized = s.catalog.photos().find(|photo| photo.file_name == "digitized.png").unwrap(); + let digitized_id = digitized.id.0; + assert_eq!(digitized.captured.as_deref(), Some("2026-01-02T10:00:00")); + let xmp = s.catalog.photos().find(|photo| photo.file_name == "xmp.png").unwrap(); + let xmp_id = xmp.id.0; + assert_eq!(xmp.captured.as_deref(), Some("2026-01-05T10:00:00+00:00")); + + let all = s.execute("photo.allMetadata", &json!({"id": digitized_id})).unwrap(); + assert!(all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), "{all}"); + let all = s.execute("photo.allMetadata", &json!({"id": xmp_id})).unwrap(); + assert!(all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), "{all}"); + assert!(all["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "xmp:CreateDate" && row["value"] == "2026-01-05T10:00:00Z"), "{all}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exif_clock_malformed_capture_cannot_fabricate_tracklog_match() { + use lightcraft_catalog::PhotoId; + + const INVALID: &str = "2026:01:01 12:34:56:99"; + const GPX: &str = r#""#; + let dir = temp_dir("exif-clock-tracklog"); + let malformed_path = dir.join("malformed.png"); + let malformed_exif = exif_clock_block(Some(INVALID), None); + write_png_with_metadata(&malformed_path, 3, Some(&malformed_exif), None); + let valid_path = dir.join("valid.png"); + let valid_exif = exif_clock_block(Some("2026:01:01 12:34:56"), None); + write_png_with_metadata(&valid_path, 4, Some(&valid_exif), None); + + let mut s = Session::new().with_fs(); + let imported = s.execute("library.import", &json!({"paths": [malformed_path.to_string_lossy()]})).unwrap(); + assert_eq!(ids(&imported, "imported"), 1, "{imported}"); + let bad_id = imported["imported"][0].as_u64().unwrap(); + assert_eq!( + s.catalog.photo(PhotoId(bad_id)).unwrap().captured.as_deref(), + Some("2026-01-14T05:58:48"), + "malformed EXIF must leave the normal file-date fallback in effect" + ); + let all = s.execute("photo.allMetadata", &json!({"id": bad_id})).unwrap(); + assert!( + all["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003) && row["value"] == INVALID), + "raw EXIF remains available: {all}" + ); + + // Leave both undo and redo populated so a no-match call must preserve the existing history branch. + s.execute("photo.setMeta", &json!({"ids": [bad_id], "title": "history control"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("library.select", &json!({"ids": [bad_id]})).unwrap(); + assert!(!s.undo.is_empty() && !s.redo.is_empty()); + let state = |session: &Session| { + ( + session.catalog.to_snapshot(), + session.catalog.revision, + session.selection.clone(), + session.commits(), + session.undo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::>(), + session.redo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::>(), + ) + }; + let before = state(&s); + let dry = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [bad_id], "maxGap": 0, "dryRun": true})).unwrap(); + assert_eq!(dry["tagged"], 0, "{dry}"); + assert_eq!(dry["skipped"]["outside"], 1, "the file-date fallback is outside the one-point track: {dry}"); + assert_eq!(state(&s), before, "dry-run changed catalog, selection or history"); + let actual = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [bad_id], "maxGap": 0})).unwrap(); + assert_eq!(actual["tagged"], 0, "{actual}"); + assert_eq!(actual["skipped"]["outside"], 1, "{actual}"); + assert_eq!(state(&s), before, "unmatched tracklog call changed catalog, selection or history"); + assert_eq!(s.catalog.photo(PhotoId(bad_id)).unwrap().meta.gps, None); + + let imported = s.execute("library.import", &json!({"paths": [valid_path.to_string_lossy()]})).unwrap(); + assert_eq!(ids(&imported, "imported"), 1, "{imported}"); + let valid_id = imported["imported"][0].as_u64().unwrap(); + assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().captured.as_deref(), Some("2026-01-01T12:34:56")); + s.execute("library.select", &json!({"ids": [valid_id]})).unwrap(); + let undo_before = s.undo.len(); + let tagged = s.execute("photo.autoTagTracklog", &json!({"gpx": GPX, "ids": [valid_id], "maxGap": 0})).unwrap(); + assert_eq!(tagged["tagged"], 1, "{tagged}"); + assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().meta.gps, Some((45.0, 7.0))); + assert_eq!(s.undo.len(), undo_before + 1, "valid tagging is one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(PhotoId(valid_id)).unwrap().meta.gps, None); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn browsing_a_folder_lists_its_photos_without_adding_them() { use crate::LibrarySource; diff --git a/crates/meta/README.md b/crates/meta/README.md index 519426784..a46c5e3c2 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -20,6 +20,10 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr 35 mm equivalent, flash, exposure bias, program, metering, white balance), orientation, GPS, dimensions and the user fields (artist, copyright, title, caption, keywords, hierarchical keywords, rating −1…5, colour label). +EXIF capture-time candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock +with more than three colon-separated components is rejected as a whole, allowing the next valid candidate or XMP +fallback to supply the capture time. The original tag remains available in All Metadata. + ## XMP namespaces Written: `dc`, `xmp`, `photoshop`, `exif`, `exifEX`, `tiff`, `lr` (`hierarchicalSubject`) and ours, diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index 4b7d09634..1e18e311d 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -43,15 +43,27 @@ impl DateTime { if dp.len() != 3 { return None; } - let tp: Vec<&str> = if t.is_empty() { vec![] } else { t.split(':').collect() }; - let g = |v: &Vec<&str>, i: usize| v.get(i).map(|x| num(x.trim())).unwrap_or(Some(0)); + let (hour, minute, second) = if t.is_empty() { + (0, 0, 0) + } else { + let mut parts = t.split(':'); + let hour = num(parts.next()?.trim())?; + let minute = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?; + let second = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?; + // A fourth value cannot be represented; accepting only the prefix could turn a + // malformed DateTimeOriginal into a plausible capture time and block fallbacks. + if parts.next().is_some() { + return None; + } + (hour, minute, second) + }; DateTime { year: num(dp[0])?.try_into().ok()?, month: num(dp[1])?.try_into().ok()?, day: num(dp[2])?.try_into().ok()?, - hour: g(&tp, 0)?.try_into().ok()?, - minute: g(&tp, 1)?.try_into().ok()?, - second: g(&tp, 2)?.try_into().ok()?, + hour: hour.try_into().ok()?, + minute: minute.try_into().ok()?, + second: second.try_into().ok()?, millis: 0, offset_minutes: None, } @@ -179,6 +191,30 @@ mod tests { assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12); } + #[test] + fn exif_clock_rejects_extra_components() { + for value in + ["2026:01:01 12:34:56:99", "2026:01:01 12:34:56:junk", "2026:01:01 12:34:56:", "2026:01:01 12:34:56::", "2026:01:01 12:34:56:99:junk"] + { + assert!(DateTime::parse_exif(value).is_none(), "accepted malformed EXIF clock {value:?}"); + } + } + + #[test] + fn exif_clock_supported_forms_keep_their_meaning() { + for (value, expected) in [ + ("2026:01:01", "2026-01-01T00:00:00"), + ("2026:01:01 12", "2026-01-01T12:00:00"), + ("2026:01:01 12:34", "2026-01-01T12:34:00"), + ("2026:01:01 12:34:56", "2026-01-01T12:34:56"), + ("2026-01-01T12:34:56", "2026-01-01T12:34:56"), + (" 2026:01:01 12:34:56\0", "2026-01-01T12:34:56"), + ("2026:01:01T12:34:60", "2026-01-01T12:34:60"), + ] { + assert_eq!(DateTime::parse_exif(value).map(|date| date.to_iso()), Some(expected.to_string()), "{value:?}"); + } + } + #[test] fn offsets() { assert_eq!(DateTime::parse_offset("+02:00"), Some(120)); diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index f24bb5f2f..30043672c 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -217,6 +217,53 @@ pub(crate) mod tests { use super::*; use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + fn capture_time_tiff(original: Option<&str>, digitized: Option<&str>, ifd0_time: Option<&str>, xmp: Option<&str>) -> Vec { + let mut exif = IfdBuilder::new(); + if let Some(value) = original { + exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii(value.into())); + } + if let Some(value) = digitized { + exif.set(t::DATE_TIME_DIGITIZED, Value::Ascii(value.into())); + } + let mut ifd0 = IfdBuilder::new(); + if let Some(value) = ifd0_time { + ifd0.set(t::DATE_TIME, Value::Ascii(value.into())); + } + ifd0.set_child(t::EXIF_IFD, exif); + if let Some(packet) = xmp { + ifd0.set(t::XMP, Value::Byte(packet.as_bytes().to_vec())); + } + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + #[test] + fn exif_clock_invalid_original_uses_the_next_valid_exif_time() { + let invalid = "2026:01:01 12:34:56:99"; + let digitized = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None); + assert_eq!(read_exif(&digitized).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00".into())); + + let ifd0 = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:"), Some("2026:01:03 11:00:00"), None); + assert_eq!(read_exif(&ifd0).capture_time.map(|date| date.to_iso()), Some("2026-01-03T11:00:00".into())); + + let none = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:junk"), Some("2026:01:03 11:00:00:"), None); + assert_eq!(read_exif(&none).capture_time, None); + + let original = capture_time_tiff(Some("2026:01:04 12:00:00"), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None); + assert_eq!(read_exif(&original).capture_time.map(|date| date.to_iso()), Some("2026-01-04T12:00:00".into())); + } + + #[test] + fn exif_clock_invalid_original_allows_xmp_fallback_and_keeps_raw_tag() { + let capture = DateTime::parse_iso("2026-01-02T10:00:00Z"); + let packet = crate::write_xmp(&Metadata { capture_time: capture, ..Default::default() }, None); + let bytes = capture_time_tiff(Some("2026:01:01 12:34:56:99"), None, None, Some(&packet)); + assert_eq!(crate::extract(&bytes).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00+00:00".into())); + + let without_fallback = capture_time_tiff(Some("2026:01:01 12:34:56:junk"), None, None, None); + assert_eq!(crate::extract(&without_fallback).capture_time, None); + assert!(crate::embedded(&without_fallback).exif.is_some(), "rejecting the projection must retain the embedded EXIF block"); + } + /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec { diff --git a/docs/gaps.md b/docs/gaps.md index 42d9d437b..f1bec72c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** patch (LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; statuses and counts unchanged; earlier: gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-25 | patch | LR-META-EXIF-FULL rejects extra clock components rather than projecting a plausible prefix; valid DateTimeDigitized, IFD0 DateTime and XMP fallbacks remain available, raw tag values stay visible, and the status / feature counts do not change | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 080680b9f..5d25b2de5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** patch (LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; readiness and feature counts unchanged; earlier: KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -169,7 +169,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | 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; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), 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` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | -| 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` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.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; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | +| 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` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.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; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets; malformed EXIF clocks with extra components are rejected so valid DateTimeDigitized, IFD0 DateTime or XMP fallbacks apply, while raw values stay visible in All Metadata (`crates/meta/src/exif.rs`, `crates/engine/src/tests_import.rs`) | ## F. Edit panel — global adjustments (EDIT) diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..1f8c8b5cd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-25 + +### Invalid EXIF capture clocks no longer become plausible dates +- An EXIF capture time with extra clock components is ignored as a whole. LightCraft can use a valid DateTimeDigitized, + IFD0 DateTime or embedded XMP value instead, while All Metadata continues to show the original tag. This also keeps + malformed timestamps from creating false matches when geotagging from a track log. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 7fd11a172..6e5fa7e7d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -31,9 +31,11 @@ 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. 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). +kept. The one exception is the **capture time**: a valid time embedded in the file (EXIF/IPTC) wins. Invalid EXIF +clock values are skipped, allowing DateTimeDigitized, IFD0 DateTime or embedded XMP to supply the time. If the file +has no valid capture time, 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).