diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 1641d63f..e946d1fa 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -183,6 +183,33 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo }) } +/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe. +fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec { + let (mut m, src) = (ph.meta.clone(), &info.meta); + let before = m.clone(); + if m.camera.is_empty() { + m.camera = src.camera.clone(); + } + if m.lens.is_empty() { + m.lens = src.lens.clone(); + } + if m.shutter.is_empty() { + m.shutter = src.shutter.clone(); + } + m.focal_mm = m.focal_mm.or(src.focal_mm); + m.aperture = m.aperture.or(src.aperture); + m.iso = m.iso.or(src.iso); + m.gps = m.gps.or(src.gps); + let mut ops = Vec::new(); + 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() }); + } + ops +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -199,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result { let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - let Some(op) = content_op(id, ph, info.clone()) else { continue }; + // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; + // nothing already set is overwritten + let meta_ops = fill_missing_meta(id, ph, &info); + let Some(op) = content_op(id, ph, info.clone()) else { + if !meta_ops.is_empty() { + ops.extend(meta_ops); + reloaded.push(id); + } + continue; + }; ops.push(op); + ops.extend(meta_ops); // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { ops.extend(content_op(c.id, c, info.clone())); @@ -277,9 +314,9 @@ pub fn edit_specs() -> Vec { cmd!( "photo.reload", "Reload from Disk", - [], + ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}", has_selection, reload ), @@ -294,3 +331,32 @@ pub fn edit_specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_catalog::{Photo, PhotoId, Source}; + + /// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps + /// everything already set. + #[test] + fn reload_fills_only_missing_camera_fields() { + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00"); + ph.meta.title = "Mine".into(); + ph.meta.iso = Some(800); + let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() }; + info.meta.camera = "Canon EOS R6 Mark III".into(); + info.meta.lens = "RF24-105mm".into(); + info.meta.iso = Some(400); + info.meta.aperture = Some(6.3); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3))); + assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept"); + assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. }))); + // nothing missing → nothing to do + ph.meta = (**meta).clone(); + ph.captured = Some("2026-10-04T08:16:11".into()); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); + } +} diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4e5c71a5..288e0262 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,6 @@ "Quit": "終了", "Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。", "Back Up Library…": "ライブラリをバックアップ…", - "Restore Library from Backup…": "バックアップからライブラリを復元…" + "Restore Library from Backup…": "バックアップからライブラリを復元…", + "Reload from Disk": "ディスクから再読み込み" } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e..d876bffb 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -186,6 +186,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "photo.saveMetadataToFile", "photo.readMetadataFromFile", + "photo.reload", "app.showInFinder", "dialog.rename", "dialog.captureTime", diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe..405cbf75 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -821,6 +821,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } + if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { + let _ = app.run("photo.reload", json!({})); + } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); } diff --git a/docs/whats-new.md b/docs/whats-new.md index 49f8634f..d2698a96 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -85,8 +85,9 @@ ### Library - Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the 1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw - data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk picks - up the full-size preview; their camera metadata comes with a fresh import. + data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in + the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they + were missing, without touching anything already set. - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through.