diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 8c5852f0a..9eb55beab 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -137,6 +137,11 @@ pub enum Op { photos: Vec, collapsed: bool, }, + /// Set a photo's capture time (ISO 8601 local time; `None` = unknown). + SetCaptured { + id: PhotoId, + captured: Option, + }, /// Rename a photo: its file name and the source it points to. Applying the op never touches /// the disk — the engine moves the file before it commits (and on undo/redo). SetFile { @@ -428,6 +433,15 @@ impl Catalog { s.collapsed = collapsed; old } + Op::SetCaptured { id, captured } => { + 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) } + } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { return Err(CatalogError::Invalid("empty file name".into())); diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index f14ebe373..3c6f1ef71 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -70,6 +70,7 @@ 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)) }, 15 => match c.photo(pid).cloned() { // a virtual copy: same source, own id, copy_of/copy_name set Some(src) => { diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 11a7eba45..ab5809358 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -2,7 +2,9 @@ use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_selection, str_param}; +use lightcraft_catalog::Op; + +use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_selection, str_param}; use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> { @@ -31,5 +33,53 @@ pub fn specs() -> Vec { Ok(json!({"renamed": n, "plans": plans})) } ), + cmd!( + "photo.setCaptureTime", + "Edit Capture Time", + [], + None, + "{ids?, time?: `2026-09-30T14:05:00` (the active photo gets it, the others shift by the same amount), each?: bool (every photo gets `time`), shift?: seconds, hours?: time-zone shift in hours} → {changed, captured: [..]}", + has_selection, + |s, p| { + use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; + let c = "photo.setCaptureTime"; + let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); + if targets.is_empty() { + return Err(bad(c, "no photos")); + } + // photos without a capture time start from their import time + let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default(); + let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64; + let mut each: Option = None; + if let Some(t) = str_param(p, "time") { + let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?; + if bool_or(p, "each", false) { + each = Some(t); + } else { + let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]); + let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; + delta += iso_seconds(&t).unwrap_or(from) - from; + } + } + let mut ops = Vec::new(); + let mut out = Vec::new(); + for id in &targets { + let new = match &each { + Some(t) => shift_iso(t, delta), + None => shift_iso(&base(s, *id), delta), + }; + let Some(new) = new else { 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) }); + } + } + let n = ops.len(); + if n > 0 { + s.commit("Edit Capture Time", Op::Batch { ops })?; + } + Ok(json!({"changed": n, "captured": out})) + } + ), ] } diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 74aad1b9e..28d1c29e1 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -186,3 +186,49 @@ fn rename_demo_photos_in_catalog() { s.execute("edit.undo", &json!({})).unwrap(); assert!(s.catalog.photo(lightcraft_catalog::PhotoId(ids[0])).unwrap().file_name.starts_with("LC")); } + +fn captured(s: &Session, id: u64) -> Option { + s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().captured.clone() +} + +/// Edit Capture Time: set (others shift along), set each, shift by an offset, time-zone shift; +/// one undo step each; replayed from the op log; the grid order follows. +#[test] +fn capture_time_set_shift_undo_and_replay() { + let dir = temp_dir("capture"); + let mut s = Session::new(); + s.open_library(&dir, true).unwrap(); + let ids: Vec = s.visible_cloned().iter().take(3).map(|p| p.0).collect(); + let before: Vec> = ids.iter().map(|i| captured(&s, *i)).collect(); + let secs = |t: &Option| lightcraft_catalog::dates::iso_seconds(t.as_deref().unwrap()).unwrap(); + // set the active photo; the others keep their distance to it + s.execute("library.select", &json!({"ids": ids, "active": ids[1]})).unwrap(); + let r = s.execute("photo.setCaptureTime", &json!({"time": "2020-01-02 03:04:05"})).unwrap(); + assert_eq!(r["changed"], 3, "{r}"); + assert_eq!(captured(&s, ids[1]).as_deref(), Some("2020-01-02T03:04:05")); + assert_eq!(secs(&captured(&s, ids[0])) - secs(&captured(&s, ids[1])), secs(&before[0]) - secs(&before[1])); + // undo restores all three in one step + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ids.iter().map(|i| captured(&s, *i)).collect::>(), before); + // shift by +1 h 30 min, then a time-zone shift of −2 h + s.execute("photo.setCaptureTime", &json!({"shift": 5400})).unwrap(); + s.execute("photo.setCaptureTime", &json!({"hours": -2})).unwrap(); + for (i, id) in ids.iter().enumerate() { + assert_eq!(secs(&captured(&s, *id)) - secs(&before[i]), 5400 - 7200); + } + // everybody the same time + s.execute("photo.setCaptureTime", &json!({"time": "2026-10-01T08:00:00", "each": true})).unwrap(); + assert!(ids.iter().all(|i| captured(&s, *i).as_deref() == Some("2026-10-01T08:00:00"))); + // the grid order (capture date, newest first) follows: they're now the newest photos + let vis = s.visible_cloned(); + assert!(vis[..3].iter().all(|v| ids.contains(&v.0)), "{vis:?}"); + // invalid dates are refused + assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-02-30 10:00"})).is_err()); + assert!(s.execute("photo.setCaptureTime", &json!({"time": "yesterday"})).is_err()); + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s = Session::new(); + s.open_library(&dir, false).unwrap(); + assert_eq!(s.catalog.to_snapshot(), expect); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 2a5ebd93a..2ad9daabc 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -456,6 +456,32 @@ mod tests { h.settle(SETTLE); } + /// Info panel → Edit Capture Time…: a time-zone shift moves the selected photos. + #[test] + fn capture_time_dialog_shifts_time_zone() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let id = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); + h.request("ui.set", json!({"view": "detail", "right": "info"}), t); + let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + let r = h.request("ui.clickWidget", json!({"id": "button:editCaptureTime"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); + if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { + *zone = -3.0; + } else { + panic!("dialog not open: {:?}", h.app.ui.dialog); + } + h.settle(SETTLE); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let after = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + let secs = lightcraft_catalog::dates::iso_seconds; + assert_eq!(secs(&after).unwrap() - secs(&before).unwrap(), -3 * 3600); + h.settle(SETTLE); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 688467489..7b35effe3 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -137,6 +137,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.readMetadataFromFile", "app.showInFinder", "dialog.rename", + "dialog.captureTime", "---", "photo.delete", ], diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aedeb75b5..1977c0608 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), ("dialog.rename", "Rename Photos…", None, "Photo"), + ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), ]; @@ -330,6 +331,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let time = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.date().replace('T', " ")).unwrap_or_default(); + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); + app.ui.dialog = + Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); + Ok(Value::Null) + } "dialog.rename" => { let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); @@ -445,7 +453,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option bool { match id { s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", - "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.copySettings" => app.session.active().is_some(), + "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { + app.session.active().is_some() + } "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "app.showInFinder" => { app.services.reveal.is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index ed3f48574..f3ac500ee 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -23,6 +23,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", Dialog::Rename { .. } => "Rename Photos", + Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", @@ -73,6 +74,59 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { + let n = app.session.targets(&json!({})).len(); + field(ui, "Change", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { + if crate::widgets::text_button(ui, &format!("captureMode-{i}"), label, mode == m).clicked() { + *mode = m.to_string(); + } + } + }); + let params = match mode.as_str() { + "set" => { + field(ui, "New time", |ui| { + let r = ui.add(egui::TextEdit::singleline(time).hint_text("2026-09-30 14:05:00").desired_width(f32::INFINITY)); + crate::widgets::register(ui.ctx(), "field:captureTime", r.rect); + }); + json!({"time": time}) + } + "shift" => { + field(ui, "Shift", |ui| { + ui.add(egui::DragValue::new(days).suffix(" d")); + ui.add(egui::DragValue::new(hours).suffix(" h")); + ui.add(egui::DragValue::new(minutes).suffix(" min")); + }); + json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}) + } + _ => { + field(ui, "Hours", |ui| ui.add(egui::DragValue::new(zone).range(-26.0..=26.0).speed(0.25).fixed_decimals(2))); + json!({"hours": zone}) + } + }; + // preview on the active photo (the others move by the same amount) + let active = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| (p.file_name.clone(), p.date().to_string())); + if let Some((name, cur)) = active { + let delta = match mode.as_str() { + "set" => lightcraft_catalog::dates::normalize_iso(time) + .and_then(|t| Some(lightcraft_catalog::dates::iso_seconds(&t)? - lightcraft_catalog::dates::iso_seconds(&cur)?)), + "shift" => params["shift"].as_i64(), + _ => Some((*zone as f64 * 3600.0).round() as i64), + }; + let after = delta.and_then(|d| lightcraft_catalog::dates::shift_iso(&cur, d)); + ui.label( + egui::RichText::new(match after { + Some(a) => format!("{name}: {} → {}", cur.replace('T', " "), a.replace('T', " ")), + None => "Enter a date as YYYY-MM-DD HH:MM:SS".into(), + }) + .color(t.text_label), + ); + } + if n > 1 { + ui.label(egui::RichText::new(format!("All {n} selected photos move by the same amount.")).color(t.text_dim)); + } + } Dialog::Rename { template, start } => { let n = app.session.targets(&json!({})).len(); ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); @@ -358,6 +412,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => app.run( + "photo.setCaptureTime", + match mode.as_str() { + "set" => json!({"time": time}), + "shift" => json!({"shift": *days as i64 * 86_400 + *hours as i64 * 3600 + *minutes as i64 * 60}), + _ => json!({"hours": zone}), + }, + ), Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index b97a099de..21e173063 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -348,7 +348,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { divider(ui); header(ui, "Camera"); let m = &p.meta; - label_row(ui, "Captured", p.captured.as_deref().unwrap_or("—")); + let cap = p.captured.as_deref().map(|c| c.replace('T', " ")).unwrap_or_else(|| "—".into()); + label_row(ui, "Captured", &cap); + padded(ui, |ui| { + if text_button(ui, "editCaptureTime", "Edit Capture Time…", false).clicked() { + let _ = app.run("dialog.captureTime", json!({})); + } + }); label_row(ui, "Camera", &m.camera); label_row(ui, "Lens", &m.lens); label_row(ui, "Focal", &m.focal_mm.map(|f| format!("{f:.0} mm")).unwrap_or_default()); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 61c561a8d..710224e10 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -151,6 +151,16 @@ pub enum Dialog { id: u64, name: String, }, + /// Edit Capture Time: `mode` "set" (`time`; the others shift along), "shift" (by `days`, + /// `hours`, `minutes`) or "zone" (time-zone shift by `zone` hours). + CaptureTime { + mode: String, + time: String, + days: i32, + hours: i32, + minutes: i32, + zone: f32, + }, /// Batch rename the selected photos with a file-name template. Rename { template: String, diff --git a/docs/parity.md b/docs/parity.md index 8f93ca313..c92183528 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -75,7 +75,7 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 12. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). 13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -14. **LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — capture-time edit, label menu/names. +14. **LR-LIB-LABEL UI** (P1) — label menu/names. 15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. 16. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). @@ -157,7 +157,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ⬜ | | | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` | template tokens {name} {seq:N} {date:%Y%m%d} {camera} {title}; preview; renames files on disk with their XMP sidecars, never overwriting (-1, -2… suffixes), rolls back on failure; undo/redo move the files; virtual copies follow | -| LR-LIB-CAPTURETIME | Edit capture time | P1 | ⬜ | | `photo.setMeta` has no capture-time field | +| LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time” | | LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ⬜ | | | | LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | | LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | @@ -588,7 +588,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-RESET | Reset edits / crop | P0 | ✅ | `cmd:develop.reset`, `cmd:crop.reset` | | | MENU-PHOTO-UPDATEAI | Update AI settings | P2 | ⬜ | | | | MENU-PHOTO-RENAME | Rename N photos… | P1 | ✅ | `cmd:dialog.rename` | live “Rename N Photos…” label | -| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ⬜ | | | +| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ✅ | `cmd:dialog.captureTime` | | | MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | `cmd:album.setCover` | | | MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | no confirmation; static label | | MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | |