From 4266b0ad7f62e5c05e1d3dc0c5ea55319a0af483 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:23:04 +1000 Subject: [PATCH] LR-LIB-CAPTURETIME: reject impossible anchor dates before shifting photos Co-authored-by: Codex --- crates/engine/src/cmd/manage.rs | 6 ++++- crates/engine/src/tests_libops.rs | 38 +++++++++++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 41b43cab9..eceafe462 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -197,7 +197,11 @@ pub fn specs() -> Vec { each = Some(t); } else { let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]); - let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; + let anchor_date = base(s, anchor); + // An impossible calendar date cannot anchor changes to the other photos. + let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or("")) + .and_then(|_| iso_seconds(&anchor_date)) + .ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; delta += iso_seconds(&t).unwrap_or(from) - from; } } diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index f9e0f4921..5d107f65d 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -551,3 +551,41 @@ fn choosing_random_starts_a_fresh_shuffle() { assert_eq!(s.sort.seed, 42, "an explicit seed is honoured"); assert!(s.sort.seed < 1 << 53); } + +fn capture_time_raw_metadata_session(raw: Option<&str>) -> Session { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = Session::new(); + let id = PhotoId(1); + let mut p = Photo::new(id, Source::Demo { scene: 0 }, "one.jpg", "JPEG", 4, 3, "2026-04-01T00:00:00"); + p.captured = raw.map(str::to_string); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection = crate::Selection::single(id); + s +} + +#[test] +fn capture_time_invalid_calendar_anchor_keeps_other_targets_and_redo() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + for raw in ["2026-02-30T12:00:00", "2026x03x01T12:00:00"] { + let mut s = capture_time_raw_metadata_session(Some(raw)); + let peer = PhotoId(2); + let mut p = Photo::new(peer, Source::Demo { scene: 0 }, "peer.jpg", "JPEG", 4, 3, "2026-10-09T00:00:00"); + p.captured = Some("2026-03-01T12:00:00.25+02:00".into()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.commit("Earlier rating", Op::SetRating { id: peer, rating: 4 }).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.selection.ids = vec![PhotoId(1), peer]; + s.selection.active = Some(PhotoId(1)); + let before = s.catalog.clone(); + let selection = s.selection.clone(); + let undo: Vec<_> = s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect(); + let redo: Vec<_> = s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect(); + let log = s.pending_log.clone(); + assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-03-03T12:00:00"})).is_err(), "{raw}"); + assert_eq!(s.catalog, before); + assert_eq!(s.selection, selection); + assert_eq!(s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::>(), undo); + assert_eq!(s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::>(), redo); + assert_eq!(s.pending_log, log); + } +} diff --git a/docs/parity.md b/docs/parity.md index c06dad1b0..ef4f11e55 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -172,7 +172,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | -| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” | +| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ✅ | `cmd:app.showInFinder` | ⌘R (see MENU-FILE-SHOWFINDER) | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | | LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection |