LR-META-LOCATION: edit GPS by hand, Show on Map
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 <noreply@anthropic.com>
This commit is contained in:
parent
6b6a925ac7
commit
1fe5dea3ab
|
|
@ -370,16 +370,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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::<Vec<_>>());
|
||||
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<Option<(f64, f64)>> = 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<f64> {
|
||||
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<f64> =
|
||||
up.split(|c: char| !(c.is_ascii_digit() || c == '.')).filter(|x| !x.is_empty()).map(|x| x.parse::<f64>().ok()).collect::<Option<_>>()?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>(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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue