Browse / github.com/storytold/lightcraft / patch
LR-IMP-LOCAL: update capture time on reload and auto-reload changed files in local folders
LR-IMP-LOCAL: update capture time on reload and auto-reload changed files in local folders ## Problem When a photo is imported without a capture timestamp in its EXIF/XMP metadata, LightCraft falls back to the file system modification time (`file_modified`) as `photo.captured`. If the file on disk is later edited externally (for example, when real EXIF tags are written with ExifTool, Lightroom, or an external editor), two issues occur: 1. **Reload from Disk refuses to update capture time:** Invoking **Photo → Reload from Disk** (`photo.reload`) probes the file and extracts its metadata, but `fill_missing_meta` in `crates/engine/src/cmd/convert.rs` previously checked `if ph.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Berschi (imported from pull request #3137, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +465 −45 in 22 files
- Review
- pending
- Shared
- 2026-10-28
- Id
430cfe4731d7dd828adea5d0
How it went
No other operator has reported using it yet.
Diff
raw · crates/catalog/src/journal.rs crates/catalog/src/lib.rs crates/catalog/src/model.rs crates/catalog/src/tests_format_version.rs crates/catalog/src/tests_journal.rs crates/catalog/src/tests_local.rs crates/engine/src/cmd/browse.rs crates/engine/src/cmd/convert.rs crates/engine/src/cmd/manage.rs crates/engine/src/import/scan.rs crates/engine/src/lightroom_catalog.rs crates/engine/src/lightroom_catalog/tests.rs crates/engine/src/sidecar.rs crates/engine/src/tests/import.rs crates/engine/src/tests/libops.rs crates/engine/src/tests/library.rs crates/engine/src/tests/view_follow.rs crates/engine/src/tests/xmp.rs crates/ui-egui/src/dialogs/import/scan.rs crates/ui-egui/src/tests/capture_time.rs crates/ui-egui/src/tests/grid.rs crates/ui-egui/src/tests/tallies.rs
@@ -52,6 +52,7 @@ //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | //! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |+//! | 10 | capture-time manual edit | `Photo.captured_manual`, `Op::SetCaptured.manual` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a@@ -76,7 +77,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added.-pub const VERSION: u32 = 9;+pub const VERSION: u32 = 10; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)]@@ -365,6 +366,11 @@ impl Journal { for r in &report.repaired { log::warn!("{SNAPSHOT}: {r}"); }+ // Catalogs created before format v10 didn't track whether capture times were file-derived+ // or manual user edits. Mark snapshot capture times as manual so reloads preserve them.+ if h.version < 10 {+ catalog.mark_captured_manual_for_upgrade();+ } (catalog, s.seq, Some(h.version)) } None => (Catalog::new(), 0, None),@@ -481,12 +487,11 @@ impl Journal { log::warn!("catalog: can't write the new library's snapshot: {e}"); } } else if report.damaged.is_none() && snapshot_version != Some(VERSION) {- report.upgraded_from = Some(snapshot_version.unwrap_or(0));+ let from = snapshot_version.unwrap_or(0);+ report.upgraded_from = Some(from); match j.snapshot(&catalog) { Ok(()) => {- if let Some(v) = report.upgraded_from {- log::info!("catalog: upgraded from format v{v} to v{VERSION}");- }+ log::info!("catalog: upgraded from format v{from} to v{VERSION}"); } // nothing lost: the old files are intact and still load; retried on next open Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"),@@ -72,6 +72,11 @@ pub type Result<T> = std::result::Result<T, CatalogError>; /// Maximum History entries kept per photo. pub const HISTORY_LIMIT: usize = 200; +/// Pre-v10 `SetCaptured` records lacked the `manual` field and were only emitted for manual user edits.+const fn default_pre_v10_manual() -> bool {+ true+}+ /// Every catalog mutation. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "camelCase")]@@ -181,6 +186,8 @@ pub enum Op { SetCaptured { id: PhotoId, captured: Option<String>,+ #[serde(default = "default_pre_v10_manual")]+ manual: bool, }, /// Assisted culling scores. SetAnalysis {@@ -899,14 +906,16 @@ impl Catalog { let p = self.photo_mut(id)?; Op::SetAnalysis { id, analysis: std::mem::replace(&mut p.analysis, analysis) } }- Op::SetCaptured { id, captured } => {+ Op::SetCaptured { id, captured, manual } => { if let Some(c) = &captured && stacks::iso_seconds(c).is_none() { return Err(CatalogError::Invalid(format!("not a date: {c}"))); } let p = self.photo_mut(id)?;- Op::SetCaptured { id, captured: std::mem::replace(&mut p.captured, captured) }+ let old_captured = std::mem::replace(&mut p.captured, captured);+ let old_manual = std::mem::replace(&mut p.captured_manual, manual);+ Op::SetCaptured { id, captured: old_captured, manual: old_manual } } Op::Relink { id, file_name, source, format } => { let p = self.photo_mut(id)?;@@ -1049,6 +1058,18 @@ impl Catalog { Ok(c) } + /// Catalogs created before format v10 didn't track whether capture times were file-derived+ /// or manual user edits. Mark existing capture times as manual on upgrade so reloads preserve+ /// their stored values. Local browsed photos are not part of the library and keep their+ /// file-derived provenance.+ pub fn mark_captured_manual_for_upgrade(&mut self) {+ for p in self.photos.values_mut() {+ if !p.local && p.captured.is_some() {+ Arc::make_mut(p).captured_manual = true;+ }+ }+ }+ /// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left /// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it /// finds in them, and walks folders up to the top): every photo, album and stack carries the@@ -1063,6 +1084,10 @@ impl Catalog { changed.push(format!("photo {} was filed as {}", p.id.0, id.0)); Arc::make_mut(p).id = *id; }+ if p.local && p.captured_manual {+ changed.push(format!("local photo {} cleared spurious captured_manual", p.id.0));+ Arc::make_mut(p).captured_manual = false;+ } } for (id, a) in self.albums.iter_mut() { if a.id != *id {@@ -236,6 +236,10 @@ pub struct Photo { /// ISO 8601 local capture time. #[serde(default)] pub captured: Option<String>,+ /// Whether capture time was intentionally edited by the user (e.g. via `photo.setCaptureTime`),+ /// rather than read or derived from the file header/sidecar at import.+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]+ pub captured_manual: bool, pub imported: String, #[serde(default)] pub meta: Meta,@@ -371,6 +375,7 @@ impl Photo { height, file_size: 0, captured: None,+ captured_manual: false, imported: imported.to_string(), meta: Meta::default(), rating: 0,@@ -97,6 +97,152 @@ fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() { assert_eq!(snapshot_version(&m), u64::from(VERSION)); } +#[test]+fn v9_library_marks_capture_times_manual_on_upgrade() {+ let mut c = Catalog::new();+ let id1 = c.alloc_photo_id();+ let mut p1 = Photo::new(id1, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");+ p1.captured = Some("2026-01-01T12:00:00".into());+ p1.captured_manual = false;+ c.apply(Op::AddPhoto { photo: Box::new(p1) }).unwrap();++ let id2 = c.alloc_photo_id();+ let p2 = Photo::new(id2, Source::File { path: "/p/2.jpg".into() }, "2.jpg", "JPEG", 100, 100, "2026-01-01");+ c.apply(Op::AddPhoto { photo: Box::new(p2) }).unwrap();++ let id3 = c.alloc_photo_id();+ let mut p3 = Photo::new(id3, Source::File { path: "/p/3.jpg".into() }, "3.jpg", "JPEG", 100, 100, "2026-01-01");+ p3.captured = Some("2026-01-01T12:00:00".into());+ p3.local = true;+ p3.captured_manual = false;+ c.apply(Op::AddPhoto { photo: Box::new(p3) }).unwrap();++ let snap = c.to_snapshot();+ let m = MemStore::new();+ m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":3,\"catalog\":{snap}}}\n").into_bytes());++ let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();+ assert_eq!(r.upgraded_from, Some(9));+ assert_eq!(snapshot_version(&m), u64::from(VERSION));+ assert!(loaded.photo(id1).unwrap().captured_manual);+ assert!(!loaded.photo(id2).unwrap().captured_manual);+ assert!(!loaded.photo(id3).unwrap().captured_manual, "local browsed photo must not be marked manual on upgrade");++ // Reopening the upgraded v10 catalog leaves values unchanged+ let (_, reopened, r2) = Journal::open(Box::new(m)).unwrap();+ assert_eq!(r2.upgraded_from, None);+ assert!(reopened.photo(id1).unwrap().captured_manual);+ assert!(!reopened.photo(id2).unwrap().captured_manual);+ assert!(!reopened.photo(id3).unwrap().captured_manual);+}++#[test]+fn v10_catalog_repair_clears_spurious_captured_manual_on_local_photos() {+ let mut c = Catalog::new();+ let id = c.alloc_photo_id();+ let mut p = Photo::new(id, Source::File { path: "/p/local.jpg".into() }, "local.jpg", "JPEG", 100, 100, "2026-01-01");+ p.captured = Some("2026-01-01T12:00:00".into());+ p.local = true;+ p.captured_manual = true;+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();++ let snap = c.to_snapshot();+ let m = MemStore::new();+ m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":10,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());++ let (_, loaded, r) = Journal::open(Box::new(m)).unwrap();+ assert!(!loaded.photo(id).unwrap().captured_manual);+ assert!(r.repaired.iter().any(|msg| msg.contains("cleared spurious captured_manual")));+}++#[test]+fn v10_library_preserves_captured_manual_values() {+ let mut c = Catalog::new();+ let id1 = c.alloc_photo_id();+ let mut p1 = Photo::new(id1, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");+ p1.captured = Some("2026-01-01T12:00:00".into());+ p1.captured_manual = false;+ c.apply(Op::AddPhoto { photo: Box::new(p1) }).unwrap();++ let id2 = c.alloc_photo_id();+ let mut p2 = Photo::new(id2, Source::File { path: "/p/2.jpg".into() }, "2.jpg", "JPEG", 100, 100, "2026-01-01");+ p2.captured = Some("2026-01-01T13:00:00".into());+ p2.captured_manual = true;+ c.apply(Op::AddPhoto { photo: Box::new(p2) }).unwrap();++ let snap = c.to_snapshot();+ let m = MemStore::new();+ m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":10,\"seq\":2,\"catalog\":{snap}}}\n").into_bytes());++ let (_, loaded, r) = Journal::open(Box::new(m)).unwrap();+ assert_eq!(r.upgraded_from, None);+ assert!(!loaded.photo(id1).unwrap().captured_manual);+ assert!(loaded.photo(id2).unwrap().captured_manual);+}++#[test]+fn v9_library_replaying_v10_records_after_failed_snapshot_preserves_explicit_manual_values() {+ let mut c = Catalog::new();+ let id1 = c.alloc_photo_id();+ let mut p1 = Photo::new(id1, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");+ p1.captured = Some("2026-01-01T12:00:00".into());+ c.apply(Op::AddPhoto { photo: Box::new(p1) }).unwrap();++ let id2 = c.alloc_photo_id();+ let mut p2 = Photo::new(id2, Source::File { path: "/p/2.jpg".into() }, "2.jpg", "JPEG", 100, 100, "2026-01-01");+ p2.captured = Some("2026-01-01T12:00:00".into());+ c.apply(Op::AddPhoto { photo: Box::new(p2) }).unwrap();++ let id3 = c.alloc_photo_id();+ let mut p3 = Photo::new(id3, Source::File { path: "/p/3.jpg".into() }, "3.jpg", "JPEG", 100, 100, "2026-01-01");+ p3.captured = Some("2026-01-01T12:00:00".into());+ c.apply(Op::AddPhoto { photo: Box::new(p3) }).unwrap();++ let snap = c.to_snapshot();+ let m = MemStore::new();+ m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":3,\"catalog\":{snap}}}\n").into_bytes());++ let op4 = Op::SetCaptured { id: id1, captured: Some("2026-01-01T14:00:00".into()), manual: false };+ let op5 = Op::SetCaptured { id: id2, captured: Some("2026-01-01T15:00:00".into()), manual: true };+ let log = format!("{}\n{}\n", encode_record(4, &op4), encode_record(5, &op5));+ m.set(LOG, log.into_bytes());++ let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();+ assert_eq!(r.upgraded_from, Some(9));+ assert_eq!(snapshot_version(&m), u64::from(VERSION));++ assert!(!loaded.photo(id1).unwrap().captured_manual);+ assert_eq!(loaded.photo(id1).unwrap().captured.as_deref(), Some("2026-01-01T14:00:00"));++ assert!(loaded.photo(id2).unwrap().captured_manual);+ assert_eq!(loaded.photo(id2).unwrap().captured.as_deref(), Some("2026-01-01T15:00:00"));++ assert!(loaded.photo(id3).unwrap().captured_manual);+ assert_eq!(loaded.photo(id3).unwrap().captured.as_deref(), Some("2026-01-01T12:00:00"));+}++#[test]+fn pre_v10_set_captured_record_in_log_deserializes_as_manual_true() {+ let mut c = Catalog::new();+ let id = c.alloc_photo_id();+ let p = Photo::new(id, Source::File { path: "/p/1.jpg".into() }, "1.jpg", "JPEG", 100, 100, "2026-01-01");+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();++ let snap = c.to_snapshot();+ let m = MemStore::new();+ m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":9,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());++ // Pre-v10 SetCaptured log record (without the "manual" field):+ let body = format!(r#"{{"op":"setCaptured","id":{},"captured":"2026-01-01T16:00:00"}}"#, id.0);+ let line = format!("{{\"seq\":2,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes()));+ m.set(LOG, line.into_bytes());++ let (_, loaded, r) = Journal::open(Box::new(m)).unwrap();+ assert_eq!(r.upgraded_from, Some(9));+ assert_eq!(loaded.photo(id).unwrap().captured.as_deref(), Some("2026-01-01T16:00:00"));+ assert!(loaded.photo(id).unwrap().captured_manual, "pre-v10 SetCaptured record without manual field must deserialize as manual: true");+}+ #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new();@@ -238,8 +384,8 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } }- /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.- const NEWEST_FIELDS: u32 = 9;+ /// v10: `Photo.captured_manual` and `Op::SetCaptured.manual`.+ const NEWEST_FIELDS: u32 = 10; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); }@@ -91,7 +91,11 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { 9 if b.is_multiple_of(2) => { Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } } }- 9 => Op::SetCaptured { id: pid, captured: (!b.is_multiple_of(3)).then(|| format!("2026-0{}-1{}T10:{:02}:00", 1 + b % 9, b % 10, a % 60)) },+ 9 => Op::SetCaptured {+ id: pid,+ captured: (!b.is_multiple_of(3)).then(|| format!("2026-0{}-1{}T10:{:02}:00", 1 + b % 9, b % 10, a % 60)),+ manual: false,+ }, 15 => match c.photo(pid).cloned() { // a virtual copy: same source, own id, copy_of/copy_name set Some(src) => {@@ -116,7 +116,7 @@ fn any_user_change_keeps_a_record() { ("gps", set(|p| p.meta.gps = Some((1.0, 2.0)))), ("copyright", set(|p| p.meta.copyright = "© me".into())), ("creator edit", set(|p| p.meta.creator = "Me".into())),- ("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()) })),+ ("capture time", op(|id| Op::SetCaptured { id, captured: Some("2020-01-01T00:00:00".into()), manual: false })), ("rename", set(|p| p.file_name = "renamed.JPG".into())), ("culling", op(|id| Op::SetAnalysis { id, analysis: Some(Analysis::default()) })), ("deleted", op(|id| Op::SetDeleted { id, deleted: true })),@@ -45,6 +45,23 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> { // looking at a folder never brings its deleted photos back (importing the files does) let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() }; let report = import_with(s, &files, &opts)?;+ // Reload local photos in this folder that have changed on disk:+ let changed_ids: Vec<PhotoId> = s+ .catalog+ .photos()+ .filter(|p| p.local && !p.deleted && p.copy_of.is_none())+ .filter_map(|p| match &p.source {+ Source::File { path } if in_folder(path, &dir_s, subfolders) => {+ let meta = std::fs::metadata(path).ok()?;+ if meta.len() != p.file_size { Some(p.id) } else { None }+ }+ _ => None,+ })+ .collect();+ if !changed_ids.is_empty() {+ let ids: Vec<Value> = changed_ids.iter().map(|id| json!(id.0)).collect();+ let _ = s.execute("photo.reload", &json!({"ids": ids}));+ } let forgotten = forget_gone(s, &dir_s, subfolders, &files); stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders });@@ -248,12 +248,24 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P if m != before { ops.push(Op::SetMeta { id, meta: Box::new(m) }); }- if ph.captured.is_none() && info.captured.is_some() {- ops.push(Op::SetCaptured { id, captured: info.captured.clone() });+ if let Some(op) = captured_op(id, ph, info) {+ ops.push(op); } ops } +/// The op that gives a photo the capture time its file reads now (`info`, a fresh probe), or none when+/// the catalog already has it, the file reads none, or the photo's capture time was intentionally edited.+/// File-derived values (embedded EXIF or import fallback) and local browsed photos are refreshed when the+/// file changes on disk; manual edits made via `photo.setCaptureTime` on library photos are preserved.+pub(crate) fn captured_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option<Op> {+ let cap = info.captured.as_deref()?;+ if (ph.captured_manual && !ph.local) || (ph.captured.as_deref() == Some(cap) && !ph.captured_manual) {+ return None;+ }+ Some(Op::SetCaptured { id, captured: Some(cap.to_string()), manual: false })+}+ /// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when /// it already has them. A catalog from before the lens data was read at import has none, so such a /// photo's correction stayed off. When the photo gains lens data and its develop settings are still@@ -303,8 +315,12 @@ pub(crate) fn reload_ops( } // virtual copies share the file for c in cat.photos().filter(|c| c.copy_of == Some(id)) {- let theirs: Vec<Op> =- content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();+ let theirs: Vec<Op> = content_op(c.id, c, info.clone())+ .into_iter()+ .chain(captured_op(c.id, c, info))+ .chain(lens_ops(c.id, c, info))+ .chain(as_shot_wb_op(c.id, c, info))+ .collect(); if !theirs.is_empty() { ops.extend(theirs); changed.push(c.id);@@ -430,6 +446,171 @@ mod tests { assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + /// Reload from Disk updates capture time when the file carries a metadata capture time that+ /// differs from the catalog record (e.g. metadata tags were added or edited externally after+ /// an initial fallback to file modification time).+ #[test]+ fn reload_updates_stale_capture_time_from_probe() {+ let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");+ ph.captured = Some("2026-10-21T01:00:06".into());+ let info = crate::media::ProbeInfo { captured: Some("2026-10-20T17:05:32+02:00".into()), ..Default::default() };+ let ops = fill_missing_meta(PhotoId(1), &ph, &info);+ assert_eq!(ops.len(), 1);+ assert_eq!(ops[0], Op::SetCaptured { id: PhotoId(1), captured: Some("2026-10-20T17:05:32+02:00".into()), manual: false });+ }++ /// A photo imported with an embedded capture time updates when the file's embedded capture time+ /// is changed externally (e.g. by ExifTool or an external editor), emitting Op::SetCaptured.+ #[test]+ fn reload_updates_externally_edited_embedded_capture_time() {+ let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");+ // Imported with an embedded EXIF capture time (not manual)+ ph.captured = Some("2020-01-01T10:00:00".into());+ ph.captured_manual = false;+ // File on disk now carries an updated embedded capture time+ let info = crate::media::ProbeInfo {+ captured: Some("2020-01-01T11:00:00".into()),+ file_modified: Some("2026-10-27T08:00:00".into()),+ ..Default::default()+ };+ let ops = fill_missing_meta(PhotoId(1), &ph, &info);+ assert_eq!(ops.len(), 1);+ assert_eq!(ops[0], Op::SetCaptured { id: PhotoId(1), captured: Some("2020-01-01T11:00:00".into()), manual: false });+ }++ #[test]+ fn reload_ops_updates_virtual_copies_capture_time() {+ let mut cat = lightcraft_catalog::Catalog::new();+ let mut master = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");+ master.captured = Some("2026-10-21T01:00:06".into());+ cat.apply(Op::AddPhoto { photo: Box::new(master.clone()) }).unwrap();++ let mut copy = master.clone();+ copy.id = PhotoId(2);+ copy.copy_of = Some(PhotoId(1));+ cat.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();++ let info = crate::media::ProbeInfo {+ captured: Some("2026-10-20T17:05:32+02:00".into()),+ width: 1920,+ height: 1080,+ file_size: master.file_size,+ format: "JPEG".into(),+ ..Default::default()+ };++ let mut ops = Vec::new();+ let mut changed = Vec::new();+ reload_ops(&cat, PhotoId(1), &info, &mut ops, &mut changed);++ assert!(ops.contains(&Op::SetCaptured { id: PhotoId(1), captured: Some("2026-10-20T17:05:32+02:00".into()), manual: false }));+ assert!(ops.contains(&Op::SetCaptured { id: PhotoId(2), captured: Some("2026-10-20T17:05:32+02:00".into()), manual: false }));+ assert_eq!(changed, vec![PhotoId(1), PhotoId(2)]);+ }++ /// A manually edited capture time (`photo.setCaptureTime`) survives reload and is not overwritten+ /// by probe metadata, even if the probe reads a different capture time from the file.+ #[test]+ fn reload_preserves_manually_edited_capture_time() {+ let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");+ ph.captured = Some("2024-05-01T12:00:00".into()); // manually edited+ ph.captured_manual = true;+ let info = crate::media::ProbeInfo {+ captured: Some("2020-01-01T10:00:00".into()),+ file_modified: Some("2026-10-21T01:00:06".into()),+ ..Default::default()+ };+ let ops = fill_missing_meta(PhotoId(1), &ph, &info);+ assert!(ops.is_empty(), "manually edited capture time must survive reload");+ }++ #[test]+ fn reload_ops_preserves_manually_edited_capture_time_on_virtual_copy() {+ let mut cat = lightcraft_catalog::Catalog::new();+ let mut master = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");+ master.captured = Some("2026-10-21T01:00:06".into()); // fallback+ master.captured_manual = false;+ cat.apply(Op::AddPhoto { photo: Box::new(master.clone()) }).unwrap();++ let mut copy = master.clone();+ copy.id = PhotoId(2);+ copy.copy_of = Some(PhotoId(1));+ copy.captured = Some("2024-05-01T12:00:00".into()); // intentional edit on copy+ copy.captured_manual = true;+ cat.apply(Op::AddPhoto { photo: Box::new(copy) }).unwrap();++ let info = crate::media::ProbeInfo {+ captured: Some("2020-01-01T10:00:00".into()),+ file_modified: Some("2026-10-21T01:00:06".into()),+ width: 1920,+ height: 1080,+ file_size: master.file_size,+ format: "JPEG".into(),+ ..Default::default()+ };++ let mut ops = Vec::new();+ let mut changed = Vec::new();+ reload_ops(&cat, PhotoId(1), &info, &mut ops, &mut changed);++ // Master had fallback, so it gets updated+ assert!(ops.contains(&Op::SetCaptured { id: PhotoId(1), captured: Some("2020-01-01T10:00:00".into()), manual: false }));+ // Copy was manually edited, so it must NOT receive Op::SetCaptured+ assert!(!ops.contains(&Op::SetCaptured { id: PhotoId(2), captured: Some("2020-01-01T10:00:00".into()), manual: false }));+ }++ #[test]+ fn photo_set_capture_time_survives_reload() {+ let mut s = crate::Session::new();+ let mut ph = Photo::new(PhotoId(1), Source::File { path: "/photos/a.jpg".into() }, "a.jpg", "JPEG", 1920, 1080, "2026-10-21T01:00:06");+ ph.captured = Some("2026-10-21T01:00:06".into()); // initial fallback+ s.catalog.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();+ s.selection = crate::Selection::single(PhotoId(1));++ // 1. Manually edit capture time via command+ s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [1], "time": "2024-05-01T12:00:00"})).unwrap();+ assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().captured.as_deref(), Some("2024-05-01T12:00:00"));+ assert!(s.catalog.photo(PhotoId(1)).unwrap().captured_manual);++ // 2. Perform reload logic with probe returning a different time from file+ let info = crate::media::ProbeInfo {+ captured: Some("2020-01-01T10:00:00".into()),+ file_modified: Some("2026-10-21T01:00:06".into()),+ width: 1920,+ height: 1080,+ format: "JPEG".into(),+ ..Default::default()+ };+ let mut ops = Vec::new();+ let mut changed = Vec::new();+ reload_ops(&s.catalog, PhotoId(1), &info, &mut ops, &mut changed);+ assert!(ops.is_empty(), "reload must not emit Op::SetCaptured for manually edited photo");+ assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().captured.as_deref(), Some("2024-05-01T12:00:00"));+ }++ #[test]+ fn local_photo_reloads_capture_time_from_disk() {+ let mut s = crate::Session::new();+ let mut ph =+ Photo::new(PhotoId(1), Source::File { path: "/photos/local.jpg".into() }, "local.jpg", "JPEG", 1920, 1080, "2026-10-27T02:00:04");+ ph.captured = Some("2026-10-27T02:00:04".into());+ ph.local = true;+ ph.captured_manual = true; // e.g. spuriously set by older upgrade+ s.catalog.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap();++ let info = crate::media::ProbeInfo {+ captured: Some("2026-10-26T19:53:01+01:00".into()),+ width: 1920,+ height: 1080,+ format: "JPEG".into(),+ ..Default::default()+ };+ let mut ops = Vec::new();+ let mut changed = Vec::new();+ reload_ops(&s.catalog, PhotoId(1), &info, &mut ops, &mut changed);+ assert_eq!(ops, vec![Op::SetCaptured { id: PhotoId(1), captured: Some("2026-10-26T19:53:01+01:00".into()), manual: false }]);+ }+ /// Reload from Disk gives a photo imported before focus points were read its camera focus point, /// and never replaces one it has. #[test]@@ -248,8 +248,10 @@ pub fn specs() -> Vec<CommandSpec> { continue; }; out.push(json!(new));- if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) {- ops.push(Op::SetCaptured { id: *id, captured: Some(new) });+ if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str())+ || s.catalog.photo(*id).is_some_and(|p| !p.captured_manual)+ {+ ops.push(Op::SetCaptured { id: *id, captured: Some(new), manual: true }); } } if out_of_range > 0 {@@ -27,13 +27,14 @@ pub(super) fn library_files(library: &lightcraft_catalog::Catalog, files: &[Stri if let Source::File { path } = &p.source && found.contains(Path::new(path)) {+ let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(p.file_size); let c = ImportCandidate { path: path.clone(), format: p.format.clone(), kind: p.kind, width: p.width, height: p.height,- file_size: p.file_size,+ file_size, captured: p.captured.clone(), duplicate: Some("path".into()), existing: Some(p.id.0),@@ -1003,17 +1003,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co if copy_masters.contains(&src.source_id) { planned.insert(photo_id, p.clone()); }- if s.catalog.photo(p.id).is_some() {+ if let Some(existing) = s.catalog.photo(p.id) { if was_local && promoted.insert(p.id) { imported += 1; ops.push(Op::SetLocal { id: p.id, local: false }); }+ let manual = existing.captured == p.captured && existing.captured_manual; ops.extend([ Op::SetRating { id: p.id, rating: p.rating }, Op::SetFlag { id: p.id, flag: p.flag }, Op::SetLabel { id: p.id, label: p.label }, Op::SetMeta { id: p.id, meta: Box::new(p.meta.clone()) },- Op::SetCaptured { id: p.id, captured: p.captured.clone() },+ Op::SetCaptured { id: p.id, captured: p.captured.clone(), manual }, ]); ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() }); } else {@@ -428,7 +428,7 @@ fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { s.close_library().unwrap(); s.open_library(&dir, false).unwrap(); let id = PhotoId(first["mapping"]["1"].as_u64().unwrap());- s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap();+ s.commit("Changed time", Op::SetCaptured { id, captured: None, manual: true }).unwrap(); let second = apply(&mut s, data, true).unwrap(); assert_eq!(first["mapping"], second["mapping"]); assert_eq!(first["imported"], 2);@@ -438,6 +438,17 @@ fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { assert_eq!(s.catalog.len(), 2); assert_eq!(s.catalog.albums().count(), 2); assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));+ assert!(!s.catalog.photo(id).unwrap().captured_manual, "changed capture time clears manual provenance");++ // When reimporting with a matching capture time, an existing manual provenance is preserved+ s.commit("Manual edit matching time", Op::SetCaptured { id, captured: Some("2024-01-16T12:34:56".into()), manual: true }).unwrap();+ assert!(s.catalog.photo(id).unwrap().captured_manual);+ let mut data_matching = sample();+ data_matching.collections.reverse();+ apply(&mut s, data_matching, true).unwrap();+ assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56"));+ assert!(s.catalog.photo(id).unwrap().captured_manual, "matching capture time preserves manual provenance");+ s.close_library().unwrap(); std::fs::remove_dir_all(dir).unwrap(); }@@ -854,7 +854,7 @@ impl Session { Op::SetMeta { id, meta: Box::new(q.meta.clone()) }, ]; if q.captured != p.captured {- ops.push(Op::SetCaptured { id, captured: q.captured.clone() });+ ops.push(Op::SetCaptured { id, captured: q.captured.clone(), manual: false }); } if develop_changed { ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));@@ -989,10 +989,10 @@ fn assisted_culling_groups_bursts_and_scores_focus() { let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; for (i, n) in ["a_soft.png", "b_sharp.png", "c_softer.png"].iter().enumerate() { let id = by_name(&s, n);- s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")) }).unwrap();+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id, captured: Some(format!("2026-05-01T10:00:0{i}")), manual: false }).unwrap(); } let other = by_name(&s, "d_other.png");- s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()) }).unwrap();+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: other, captured: Some("2026-05-01T10:00:04".into()), manual: false }).unwrap(); let r = s.execute("photo.analyze", &json!({"ids": ids, "rejectBelow": 0.0, "pickBest": true})).unwrap(); assert_eq!(r["groups"], 1, "{r}"); let a = |s: &Session, n: &str| s.catalog.photo(by_name(s, n)).unwrap().analysis.unwrap();@@ -1060,8 +1060,8 @@ fn assisted_culling_groups_bursts_across_month_end() { ("2026-11-30T23:59:59", "2026-12-01T00:00:00"), ("2026-12-31T23:59:59", "2027-01-01T00:00:00"), ] {- s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();- s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()), manual: false }).unwrap();+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()), manual: false }).unwrap(); let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap(); assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}"); }@@ -1095,8 +1095,8 @@ fn assisted_culling_burst_limits_are_adjustable() { let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));- s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()) }).unwrap();- s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()) }).unwrap();+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some("2026-05-01T10:00:00".into()), manual: false }).unwrap();+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some("2026-05-01T10:00:15".into()), manual: false }).unwrap(); // 15 s apart: past the default 10 s, inside a 20 s gap assert_eq!(s.execute("photo.analyze", &json!({"ids": ids})).unwrap()["groups"], 0); assert_eq!(s.execute("photo.analyze", &json!({"ids": ids, "burstGap": 20})).unwrap()["groups"], 1);@@ -421,7 +421,7 @@ fn tracklog_auto_tag() { s.open_library(dir.join("lib"), true).unwrap(); let ids: Vec<u64> = s.visible_cloned().iter().take(4).map(|p| p.0).collect(); let set = |s: &mut Session, id: u64, t: &str| {- s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()) }).unwrap();+ s.commit("t", Op::SetCaptured { id: PhotoId(id), captured: Some(t.into()), manual: false }).unwrap(); }; set(&mut s, ids[0], "2026-05-01T12:00:30"); // camera clock on +02:00, no zone recorded set(&mut s, ids[1], "2026-05-01T12:01:00+02:00"); // zone recorded: exactly on the 2nd point@@ -546,7 +546,7 @@ fn capture_time_shift_counts_photos_whose_time_does_not_read() { let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); let (good, bad) = (ids[0], ids[1]); for (id, t) in [(good, "2026-01-01T10:00:00"), (bad, "2026-01-01T10:00:00 and more")] {- s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();+ s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()), manual: false }).unwrap(); } let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [good.0, bad.0], "hours": 1})).unwrap(); assert_eq!(r["unreadable"], 1, "the unreadable photo is counted: {r}");@@ -574,7 +574,7 @@ fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() { let (bad, other) = (ids[0], ids[1]); let broken = "2026-01-01T10:00:00 and more"; for (id, t) in [(bad, broken), (other, "2026-01-01T12:00:00")] {- s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();+ s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()), manual: false }).unwrap(); } s.execute("library.select", &serde_json::json!({"ids": [bad.0, other.0], "active": bad.0})).unwrap(); assert_eq!(s.active(), Some(bad));@@ -586,7 +586,7 @@ fn capture_time_set_refuses_an_active_photo_whose_time_does_not_read() { let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0], "time": "2026-01-01T11:00:00"})).unwrap(); assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(1), Some(0)), "{r}"); assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "alone, it gets the time");- s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()) }).unwrap();+ s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id: bad, captured: Some(broken.into()), manual: false }).unwrap(); let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [bad.0, other.0], "time": "2026-01-01T11:00:00", "each": true})).unwrap(); assert_eq!((r["changed"].as_u64(), r["unreadable"].as_u64()), (Some(2), Some(0)), "{r}"); assert_eq!(captured(&s, bad).as_deref(), Some("2026-01-01T11:00:00"), "each repairs it");@@ -65,8 +65,10 @@ fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) { // (ops a command would refuse here, or on a photo that is gone, are edits that were not made: also a case) match dice.roll(9) { 0 => {- let _ =- s.commit("Capture time", Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)) });+ let _ = s.commit(+ "Capture time",+ Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)), manual: false },+ ); return; } 1 => {@@ -1327,7 +1327,7 @@ fn sidecar_capture_time_fills_in_when_the_file_has_none() { // Read Metadata from File fills in a missing capture time too let id = sample.id;- s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None }).unwrap();+ s.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: None, manual: false }).unwrap(); std::fs::copy(src.join("sample.jpg.xmp"), format!("{path}.xmp")).unwrap(); s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); s.execute("photo.readMetadataFromFile", &json!({})).unwrap();@@ -186,6 +186,26 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { let listed: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect(); app.session.forget_gone_from(&b.path, b.subfolders, &listed); }+ // Reload local photos in this folder whose file on disk has changed size:+ let mut stale: std::collections::BTreeSet<lightcraft_catalog::PhotoId> = std::collections::BTreeSet::new();+ for c in out.candidates.iter().filter(|c| c.duplicate == Some("path".into())) {+ let Some(existing_id) = c.existing else { continue };+ let Some(mut ph) = app.session.catalog.photo(lightcraft_catalog::PhotoId(existing_id)) else { continue };+ if let Some(master_id) = ph.copy_of {+ let Some(master) = app.session.catalog.photo(master_id) else { continue };+ ph = master;+ }+ if !ph.local || ph.deleted {+ continue;+ }+ if c.file_size != ph.file_size {+ stale.insert(ph.id);+ }+ }+ if !stale.is_empty() {+ let ids: Vec<serde_json::Value> = stale.iter().map(|id| serde_json::json!(id.0)).collect();+ let _ = app.run("photo.reload", serde_json::json!({"ids": ids}));+ } // what the library doesn't know yet joins the Local view let queue: Vec<String> = out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect();@@ -14,7 +14,7 @@ const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(60); fn set_captured(h: &mut Headless, id: lightcraft_catalog::PhotoId, t: &str) {- h.app.session.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()) }).unwrap();+ h.app.session.catalog.apply(lightcraft_catalog::Op::SetCaptured { id, captured: Some(t.into()), manual: false }).unwrap(); } fn painted(h: &Headless, part: &str) -> bool {@@ -293,7 +293,7 @@ fn a_batch_of_new_photos_or_an_edit_to_one_is_read_photo_by_photo() { assert_as_made_afresh(&mut h, "after the batch"); // a photo taken on another day: it moves there, and only it is read let read = h.app.caches.grid_stats.photos_read;- h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()) }).unwrap();+ h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()), manual: false }).unwrap(); h.step(); assert_eq!(h.app.caches.grid_stats.photos_read - read, 1, "only the photo edited is read"); assert!(widget(&h, "group:2027-02-01").is_some(), "its new day heads the grid");@@ -331,10 +331,10 @@ fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() { let _ = h.app.session.commit("Delete", Op::SetDeleted { id: PhotoId(id), deleted: true }); } 2 => {- let _ = h- .app- .session- .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)) });+ let _ = h.app.session.commit(+ "Capture time",+ Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)), manual: false },+ ); } 3 => { h.app.session.execute("library.select", &json!({"ids": [id]})).unwrap();@@ -79,10 +79,10 @@ fn a_tally_kept_through_an_edit_is_the_tally_made_from_every_photo() { let source = Source::File { path: format!("/elsewhere/{}/{name}", n % 3) }; Some(a.session.commit("Locate", Op::Relink { id: PhotoId(id), file_name: name, source, format: None })) }- 1 => Some(- a.session- .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)) }),- ),+ 1 => Some(a.session.commit(+ "Capture time",+ Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)), manual: false },+ )), 2 if n.is_multiple_of(3) => Some(a.session.commit("Remove", Op::RemovePhoto { id: PhotoId(id) })), 2 if n % 3 == 1 => { let new = a.session.catalog.alloc_photo_id();