From 1fe5dea3ab06536d644467fe79db765760ac2959 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:56:13 -0400 Subject: [PATCH] LR-META-LOCATION: edit GPS by hand, Show on Map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit photo.setMeta {gps: "lat, lon" | [lat, lon] | null} — decimal, or degrees / minutes / seconds with N/S/E/W ("48°51'30"N 2°17'40"E"); applied to all target photos, saved in XMP / exports. Info: the GPS line is an editable field, and Show on Map opens the place in OpenStreetMap. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 64 +++++++++++++++++++++++++++++- crates/engine/src/tests_xmp.rs | 16 ++++++++ crates/ui-egui/src/panels/right.rs | 35 +++++++++++++--- docs/parity.md | 4 +- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3e47d586..7bb1e33c 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -370,16 +370,33 @@ pub fn specs() -> Vec { "Edit Info", [], None, - "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", + "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}", has_selection, |s, p| { let strs = |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); let targets = s.targets(p); + // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear + let gps: Option> = match p.get("gps") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(t)) if t.trim().is_empty() => Some(None), + Some(Value::String(t)) => Some(Some( + parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?, + )), + Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) { + (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))), + _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")), + }, + Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")), + }; let mut ops = Vec::new(); for id in targets { let Some(ph) = s.catalog.photo(id) else { continue }; let mut m = ph.meta.clone(); + if let Some(g) = gps { + m.gps = g; + } for (k, field) in [ ("title", &mut m.title), ("caption", &mut m.caption), @@ -900,3 +917,48 @@ impl Session { fn has_library(s: &Session) -> std::result::Result<(), String> { if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } } + +/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W", +/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs). +pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> { + let t = t.trim(); + // split into the two coordinates: at a comma, else after the first N/S hemisphere letter + let (a, b) = match t.split_once(',') { + Some((a, b)) => (a.trim().to_string(), b.trim().to_string()), + None => { + let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?; + (t[..i].trim().to_string(), t[i..].trim().to_string()) + } + }; + let one = |s: &str, pos: char, neg: char| -> Option { + let up = s.to_ascii_uppercase(); + let sign = if up.contains(neg) || up.trim_start().starts_with('-') { -1.0 } else { 1.0 }; + let nums: Vec = + up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::().ok()).collect::>()?; + let v = match nums.as_slice() { + [d] => *d, + [d, m] => d + m / 60.0, + [d, m, s] => d + m / 60.0 + s / 3600.0, + _ => return None, + }; + let _ = pos; + Some(sign * v) + }; + let (la, lo) = (one(&a, 'N', 'S')?, one(&b, 'E', 'W')?); + (la.abs() <= 90.0 && lo.abs() <= 180.0).then_some((la, lo)) +} + +#[cfg(test)] +mod gps_tests { + #[test] + fn coordinates_people_type() { + let p = super::parse_gps; + assert_eq!(p("51.5072, -0.1276"), Some((51.5072, -0.1276))); + let (la, lo) = p("51°30'26\"N 0°7'39\"W").unwrap(); + assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}"); + let (la, lo) = p("33.8688 S, 151.2093 E").unwrap(); + assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9); + assert_eq!(p("95, 10"), None); + assert_eq!(p("hello"), None); + } +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index bc80afc0..1375655d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -399,3 +399,19 @@ fn duplicate_copies_the_file_and_the_edits() { assert!(s.catalog.photo(dup).is_none(), "one undo step"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn gps_typed_in_is_saved_to_xmp() { + let mut s = Session::with_demo(); + let id = s.active().unwrap_or_else(|| s.catalog.photos().next().unwrap().id); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.setMeta", &json!({"gps": "48°51'30\"N 2°17'40\"E"})).unwrap(); + let (la, lo) = s.catalog.photo(id).unwrap().meta.gps.unwrap(); + assert!((la - 48.8583).abs() < 1e-3 && (lo - 2.2944).abs() < 1e-3, "{la} {lo}"); + let x = crate::sidecar::sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog); + let back = lightcraft_meta::parse_xmp(&x).unwrap().metadata.gps.unwrap(); + assert!((back.latitude - la).abs() < 1e-5 && (back.longitude - lo).abs() < 1e-5); + assert!(s.execute("photo.setMeta", &json!({"gps": "north pole-ish"})).is_err()); + s.execute("photo.setMeta", &json!({"gps": null})).unwrap(); + assert!(s.catalog.photo(id).unwrap().meta.gps.is_none()); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3332bc03..aa6a9473 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -463,11 +463,36 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ] { meta_field(app, ui, label, key, value, 1); } - small(ui, "GPS"); - let gps = m.gps.map(|(la, lo)| { - format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }) - }); - ui.label(egui::RichText::new(gps.unwrap_or_else(|| "—".into())).color(t.text_label)); + // GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it + let gps = m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default(); + let gid = egui::Id::new("info-gps"); + ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); + let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("latitude, longitude").desired_width(f32::INFINITY)); + register(ui.ctx(), "field:gps", r.rect); + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(gid, text.clone())); + } else { + ui.data_mut(|d| d.remove::(gid)); + } + if r.lost_focus() + && text.trim() != gps + && let Err(e) = app.run("photo.setMeta", json!({"gps": text.trim()})) + { + app.toast(ui.ctx(), e); + } + if let Some((la, lo)) = m.gps { + let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" }); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim)); + if text_button(ui, "showOnMap", "Show on Map", false).on_hover_text("Open the place in OpenStreetMap").clicked() { + let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}"); + if let Err(e) = crate::links::open(app, &url) { + app.toast(ui.ctx(), e); + } + } + }); + } // offline originals / smart previews if let lightcraft_catalog::Source::File { path } = &p.source { let online = std::path::Path::new(path).exists(); diff --git a/docs/parity.md b/docs/parity.md index 5fb2c138..50f7bd00 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -191,7 +191,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | -| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`) | place fields edited in Info, read/written as IPTC Core / Photoshop XMP; GPS read-only; no map, no geocoding | +| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright, creator, place fields, keywords; `fields` picks a subset | | LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | @@ -438,7 +438,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`), `crates/ui-egui/src/panels/right.rs` | location, city, state/province, country editable; GPS shown; no map | +| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `crates/ui-egui/src/panels/right.rs` | location, city, state/province, country and GPS editable; Show on Map (OpenStreetMap in the browser); no map in the panel | | LR-MAP-MODULE | Map module [Classic] | P2 | ⬜ | | | ## V. Preferences (PREF)