Merge PR #2977: LR-META-EXIF-FULL: reject malformed EXIF clocks

docs/gaps.md, docs/parity-checklist.md, docs/whats-new.md: both sides kept; the PR's sentences added to main's rows and entries.
docs/xmp-interop.md: capture-time paragraph combines both sides.
The PR's test file crates/engine/src/tests_import.rs landed in crates/engine/src/tests/import.rs (moved on main).
This commit is contained in:
aiongg
2026-10-31 02:08:25 +00:00
10 changed files with 242 additions and 17 deletions
+1 -3
View File
@@ -497,9 +497,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())
+128
View File
@@ -30,6 +30,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<u8> {
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)
}
@@ -128,6 +156,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#"<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/" xmp:CreateDate="2026-01-05T10:00:00Z"/></rdf:RDF></x:xmpmeta>"#;
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#"<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg><trkpt lat="45" lon="7"><time>2026-01-01T12:34:56Z</time></trkpt></trkseg></trk></gpx>"#;
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::<Vec<_>>(),
session.redo.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::<Vec<_>>(),
)
};
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;
+4
View File
@@ -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,
+41 -5
View File
@@ -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,
}
@@ -196,6 +208,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));
+47
View File
@@ -242,6 +242,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<u8> {
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<u8> {
+6 -1
View File
@@ -606,7 +606,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);
+2 -1
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (gap 8: lens profile work is tracked in #329; 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:** minor (LR-META-EXIF-FULL rejects extra EXIF clock components and preserves date fallbacks; gap 8: lens profile work is tracked in #329; 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
@@ -86,6 +86,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|---|---|---|
| 2026-10-25 | patch | ISO timestamp parsing rejects incomplete or trailing clock/fraction text; XMP falls back to a valid capture-time property and GPX counts malformed times as untimed; ranked gaps and readiness unchanged |
| 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged |
| 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 |
+2 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; 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; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-META-EXIF-FULL: reject extra EXIF clock components so invalid capture-time candidates fall through to valid metadata; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; 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; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched) · **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; keywords are saved as names in `dc:subject` and paths in `lr:hierarchicalSubject` (`keywords::to_file`; before, paths went into `dc:subject`); 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 (`<file>.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 `<file>.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`); malformed XMP capture-time values remain in the raw property map and are not projected, allowing the next valid field to supply the capture time |
| 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)
+5
View File
@@ -16,6 +16,11 @@
different valid time. XMP keeps the original property available and can use the next valid capture-time field;
GPX treats an invalid point time as untimed, so it cannot tag a photo at a fabricated time.
### 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
### Sidecar keywords stay searchable in other applications
+6 -5
View File
@@ -31,11 +31,12 @@ 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).
An incomplete or malformed XMP timestamp is not projected as a capture time, remains available in the raw property
map, and does not prevent the next valid capture-time property from being used.
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). An incomplete or malformed XMP timestamp is not projected as a capture time,
remains available in the raw property map, and does not prevent the next valid capture-time property from being used.
`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).