mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
LR-META-XMP: reject incomplete ISO timestamps
This commit is contained in:
committed by
Roni Balthazar
parent
31ec62cd03
commit
07d1f0ad5d
@@ -467,6 +467,58 @@ fn tracklog_auto_tag() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracklog_auto_tag_rejects_malformed_capture_time_without_history_changes() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned().first().map(|id| id.0).unwrap();
|
||||
s.execute("library.select", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("photo.setCaptureTime", &json!({"time": "2026-01-01T00:00:00", "each": true})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "gps": null, "title": "before"})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"ids": [id], "title": "after"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.undo.is_empty() && !s.redo.is_empty(), "test starts with undo and redo history");
|
||||
|
||||
let gpx = 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-01T</time></trkpt>
|
||||
<trkpt lat="46" lon="8"><time>2026-01-01T01:00:00Z</time></trkpt>
|
||||
</trkseg></trk></gpx>"#;
|
||||
let photo = s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap();
|
||||
assert_eq!(photo.meta.gps, None);
|
||||
assert_eq!(photo.meta.title.as_str(), "before");
|
||||
|
||||
let before_catalog = s.catalog.to_snapshot();
|
||||
let before_revision = s.catalog.revision;
|
||||
let before_selection = s.selection.clone();
|
||||
let before_commits = s.commits();
|
||||
let before_history = |entries: &[crate::UndoEntry]| entries.iter().map(|entry| (entry.label.clone(), entry.op.clone())).collect::<Vec<_>>();
|
||||
let before_undo = before_history(&s.undo);
|
||||
let before_redo = before_history(&s.redo);
|
||||
|
||||
for dry_run in [true, false] {
|
||||
let result = s.execute("photo.autoTagTracklog", &json!({"gpx": gpx, "ids": [id], "maxGap": 0, "dryRun": dry_run})).unwrap();
|
||||
assert_eq!(result["tagged"], 0, "{result}");
|
||||
assert_eq!(s.catalog.to_snapshot(), before_catalog);
|
||||
assert_eq!(s.catalog.revision, before_revision);
|
||||
assert_eq!(s.selection, before_selection);
|
||||
assert_eq!(s.commits(), before_commits);
|
||||
assert_eq!(before_history(&s.undo), before_undo);
|
||||
assert_eq!(before_history(&s.redo), before_redo);
|
||||
}
|
||||
|
||||
let valid_midnight = 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-01T00:00:00Z</time></trkpt>
|
||||
</trkseg></trk></gpx>"#;
|
||||
let undo_depth = s.undo.len();
|
||||
let commits = s.commits();
|
||||
let result = s.execute("photo.autoTagTracklog", &json!({"gpx": valid_midnight, "ids": [id], "maxGap": 0})).unwrap();
|
||||
assert_eq!(result["tagged"], 1, "{result}");
|
||||
assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.gps, Some((45.0, 7.0)));
|
||||
assert_eq!(s.undo.len(), undo_depth + 1);
|
||||
assert_eq!(s.commits(), commits + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.gps, None);
|
||||
}
|
||||
|
||||
// ---- random sort: Given the library, When the user picks Random, Then the grid is a stable shuffle
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,10 @@ XMP parser; truncation tests cut a sample JPEG at every byte and exercise trunca
|
||||
image-resource records with hostile declared lengths.
|
||||
PSD/PSB XMP resources require valid UTF-8; trailing NUL padding is trimmed after validation.
|
||||
|
||||
ISO capture-time parsing requires a complete clock and consumes every clock and fractional-second character. Invalid
|
||||
XMP capture-time values remain available in the raw property map but are not projected into `Metadata`; a later valid
|
||||
capture-time property can still supply the metadata value. Invalid GPX point times are treated as untimed.
|
||||
|
||||
## Sources
|
||||
|
||||
CIPA DC-008 (Exif 2.32), TIFF 6.0, Adobe DNG 1.7 (tag numbers), ISO 16684-1 / Adobe XMP Specification
|
||||
|
||||
@@ -93,7 +93,11 @@ impl DateTime {
|
||||
/// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`.
|
||||
pub fn parse_iso(s: &str) -> Option<DateTime> {
|
||||
let s = s.trim();
|
||||
let (d, t) = s.split_once('T').unwrap_or((s, ""));
|
||||
let (d, t) = match s.split_once('T') {
|
||||
Some((d, t)) if !t.is_empty() => (d, t),
|
||||
Some(_) => return None,
|
||||
None => (s, ""),
|
||||
};
|
||||
let dp: Vec<&str> = d.split('-').collect();
|
||||
let year = num(dp.first()?)?.try_into().ok()?;
|
||||
let month = dp.get(1).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?;
|
||||
@@ -107,13 +111,26 @@ impl DateTime {
|
||||
Some(i) => (&t[..i], Some(&t[i..])),
|
||||
None => (t, None),
|
||||
};
|
||||
let cp: Vec<&str> = clock.split(':').collect();
|
||||
dt.hour = num(cp.first()?)?.try_into().ok()?;
|
||||
dt.minute = num(cp.get(1)?)?.try_into().ok()?;
|
||||
if let Some(sec) = cp.get(2) {
|
||||
let (whole, frac) = sec.split_once('.').unwrap_or((sec, ""));
|
||||
let mut cp = clock.split(':');
|
||||
dt.hour = num(cp.next()?)?.try_into().ok()?;
|
||||
dt.minute = num(cp.next()?)?.try_into().ok()?;
|
||||
if let Some(sec) = cp.next() {
|
||||
let (whole, fraction) = match sec.split_once('.') {
|
||||
Some((whole, fraction)) => {
|
||||
if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
(whole, Some(fraction))
|
||||
}
|
||||
None => (sec, None),
|
||||
};
|
||||
dt.second = num(whole)?.try_into().ok()?;
|
||||
dt = dt.with_subsec(frac);
|
||||
if let Some(fraction) = fraction {
|
||||
dt = dt.with_subsec(fraction);
|
||||
}
|
||||
}
|
||||
if cp.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(o) = off {
|
||||
dt.offset_minutes = Some(Self::parse_offset(o)?);
|
||||
@@ -215,6 +232,36 @@ mod tests {
|
||||
assert!(DateTime::parse_iso("2019-07-04Tab:cd").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_rejects_incomplete_or_unconsumed_clock_text() {
|
||||
for value in [
|
||||
"2026-01-01T",
|
||||
"2026-01-01T12:34:56:99Z",
|
||||
"2026-01-01T12:34:56.Z",
|
||||
"2026-01-01T12:34:56.123junkZ",
|
||||
"2026-01-01T12:34:56.1.2Z",
|
||||
"2026-01-01T12:34:56.12 3Z",
|
||||
"2026-01-01T12:34:56:99Z",
|
||||
"2026-01-01T12::34Z",
|
||||
] {
|
||||
assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_validates_clock_while_preserving_supported_forms() {
|
||||
assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019-01-01T00:00:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07").unwrap().to_iso(), "2019-07-01T00:00:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04").unwrap().to_iso(), "2019-07-04T00:00:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().to_iso(), "2019-07-04T10:11:00");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12").unwrap().to_iso(), "2019-07-04T10:11:12");
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12Z").unwrap().offset_minutes, Some(0));
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12-05:30").unwrap().offset_minutes, Some(-330));
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12.123456Z").unwrap().millis, 123);
|
||||
assert_eq!(DateTime::parse_iso(" 2019-07-04T10:11:12.5Z ").unwrap().millis, 500);
|
||||
assert_eq!(DateTime::parse_iso("2019-07-04T10:11:60Z").unwrap().second, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix() {
|
||||
assert_eq!(DateTime::parse_iso("1970-01-01T00:00:00Z").unwrap().unix_seconds(), 0);
|
||||
|
||||
@@ -303,6 +303,19 @@ mod tests {
|
||||
assert_eq!(log.points[0].time, 1_777_629_600.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_iso_time_is_untimed_and_cannot_match_midnight() {
|
||||
let source = 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-01T</time></trkpt>
|
||||
<trkpt lat="46" lon="8"><time>2026-01-01T01:00:00Z</time></trkpt>
|
||||
</trkseg></trk></gpx>"#;
|
||||
let log = parse_gpx(source).unwrap();
|
||||
assert_eq!(log.untimed, 1);
|
||||
assert_eq!(log.points.len(), 1);
|
||||
assert_eq!((log.points[0].latitude, log.points[0].longitude), (46.0, 8.0));
|
||||
assert!(log.locate(t("2026-01-01T00:00:00Z"), 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolates_within_a_segment() {
|
||||
let log = parse_gpx(LOG).unwrap();
|
||||
|
||||
@@ -894,6 +894,25 @@ mod tests {
|
||||
assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_capture_time_falls_back_and_keeps_raw_property() {
|
||||
let packet = 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:exif="http://ns.adobe.com/exif/1.0/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" exif:DateTimeOriginal="2026-01-01T" xmp:CreateDate="2026-01-02T10:00:00Z"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let parsed = parse_xmp(packet).unwrap();
|
||||
assert_eq!(
|
||||
parsed.metadata.capture_time.map(|d| (d.year, d.month, d.day, d.hour, d.minute, d.second, d.offset_minutes)),
|
||||
Some((2026, 1, 2, 10, 0, 0, Some(0)))
|
||||
);
|
||||
assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_capture_time_without_fallback_is_not_projected() {
|
||||
let packet = 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:exif="http://ns.adobe.com/exif/1.0/" exif:DateTimeOriginal="2026-01-01T"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let parsed = parse_xmp(packet).unwrap();
|
||||
assert_eq!(parsed.metadata.capture_time, None);
|
||||
assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs() {
|
||||
assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err());
|
||||
|
||||
+2
-1
@@ -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:** 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; 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, readiness, and estimates unchanged)
|
||||
|
||||
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 | 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 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 |
|
||||
|
||||
@@ -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:** 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; LR-META-XMP / LRC-MAP-TRACKLOG: malformed ISO times are not projected or matched; feature counts unchanged) · **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
|
||||
@@ -168,7 +168,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| 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`), `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 (`<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`) |
|
||||
| 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 (`<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 |
|
||||
|
||||
## F. Edit panel — global adjustments (EDIT)
|
||||
@@ -737,7 +737,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-MAP-VIEW | Map view | P2 | ⬜ | | |
|
||||
| LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | |
|
||||
| LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | |
|
||||
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module) |
|
||||
| LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module); incomplete or malformed ISO point times are untimed and cannot match a photo |
|
||||
| LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | |
|
||||
| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | `cmd:metadata.reverseGeocode` | offline from GeoNames (City / State / Country, no sublocation); the list holds towns of 15 000 people or more, so a smaller town gets the nearest larger one (the tooltip and command help say so); `maxDistance` leaves City empty past a distance and names the town as `nearestCity` (issue #2242); no online provider |
|
||||
| LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | |
|
||||
@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | patch | LR-META-XMP and LRC-MAP-TRACKLOG reject incomplete ISO timestamps: XMP capture-time fallback remains available and malformed GPX times are untimed; status and feature counts unchanged |
|
||||
| 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) |
|
||||
| 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |
|
||||
| 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged |
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-10-25
|
||||
|
||||
### Incomplete timestamps no longer become plausible photo times
|
||||
- An ISO date ending in `T`, a clock with extra components, or a malformed fractional second is no longer read as a
|
||||
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.
|
||||
|
||||
## 2026-10-24
|
||||
|
||||
### Relative-date filters and smart albums follow the clock
|
||||
|
||||
@@ -34,6 +34,8 @@ Reading merges into the catalog with the **sidecar winning** for every field it
|
||||
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.
|
||||
`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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user