collective

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

diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs--- a/crates/catalog/src/journal.rs+++ b/crates/catalog/src/journal.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}"),diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs--- a/crates/catalog/src/lib.rs+++ b/crates/catalog/src/lib.rs@@ -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 {diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs--- a/crates/catalog/src/model.rs+++ b/crates/catalog/src/model.rs@@ -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,diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs--- a/crates/catalog/src/tests_format_version.rs+++ b/crates/catalog/src/tests_format_version.rs@@ -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"); }diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs--- a/crates/catalog/src/tests_journal.rs+++ b/crates/catalog/src/tests_journal.rs@@ -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) => {diff --git a/crates/catalog/src/tests_local.rs b/crates/catalog/src/tests_local.rs--- a/crates/catalog/src/tests_local.rs+++ b/crates/catalog/src/tests_local.rs@@ -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 })),diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs--- a/crates/engine/src/cmd/browse.rs+++ b/crates/engine/src/cmd/browse.rs@@ -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 });diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs--- a/crates/engine/src/cmd/convert.rs+++ b/crates/engine/src/cmd/convert.rs@@ -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]diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs--- a/crates/engine/src/cmd/manage.rs+++ b/crates/engine/src/cmd/manage.rs@@ -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 {diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs--- a/crates/engine/src/import/scan.rs+++ b/crates/engine/src/import/scan.rs@@ -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),diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs--- a/crates/engine/src/lightroom_catalog.rs+++ b/crates/engine/src/lightroom_catalog.rs@@ -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 {diff --git a/crates/engine/src/lightroom_catalog/tests.rs b/crates/engine/src/lightroom_catalog/tests.rs--- a/crates/engine/src/lightroom_catalog/tests.rs+++ b/crates/engine/src/lightroom_catalog/tests.rs@@ -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(); }diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs--- a/crates/engine/src/sidecar.rs+++ b/crates/engine/src/sidecar.rs@@ -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"));diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs--- a/crates/engine/src/tests/import.rs+++ b/crates/engine/src/tests/import.rs@@ -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);diff --git a/crates/engine/src/tests/libops.rs b/crates/engine/src/tests/libops.rs--- a/crates/engine/src/tests/libops.rs+++ b/crates/engine/src/tests/libops.rs@@ -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 pointdiff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs--- a/crates/engine/src/tests/library.rs+++ b/crates/engine/src/tests/library.rs@@ -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");diff --git a/crates/engine/src/tests/view_follow.rs b/crates/engine/src/tests/view_follow.rs--- a/crates/engine/src/tests/view_follow.rs+++ b/crates/engine/src/tests/view_follow.rs@@ -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 => {diff --git a/crates/engine/src/tests/xmp.rs b/crates/engine/src/tests/xmp.rs--- a/crates/engine/src/tests/xmp.rs+++ b/crates/engine/src/tests/xmp.rs@@ -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();diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs--- a/crates/ui-egui/src/dialogs/import/scan.rs+++ b/crates/ui-egui/src/dialogs/import/scan.rs@@ -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();diff --git a/crates/ui-egui/src/tests/capture_time.rs b/crates/ui-egui/src/tests/capture_time.rs--- a/crates/ui-egui/src/tests/capture_time.rs+++ b/crates/ui-egui/src/tests/capture_time.rs@@ -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 {diff --git a/crates/ui-egui/src/tests/grid.rs b/crates/ui-egui/src/tests/grid.rs--- a/crates/ui-egui/src/tests/grid.rs+++ b/crates/ui-egui/src/tests/grid.rs@@ -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();diff --git a/crates/ui-egui/src/tests/tallies.rs b/crates/ui-egui/src/tests/tallies.rs--- a/crates/ui-egui/src/tests/tallies.rs+++ b/crates/ui-egui/src/tests/tallies.rs@@ -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();