From b28b05c4751942bbd66881b80e15e381bc4ef328 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 20:49:36 +1100 Subject: [PATCH 1/2] Reverse geocoding: fill City / State / Country from GPS (offline GeoNames) New crate lightcraft-geocode (L1): parses the GeoNames cities dump and finds the nearest place within 50 km through a 1-degree cell index; NaN / out-of-range positions and malformed lines find nothing. Engine commands metadata.reverseGeocode (Photo menu, Info panel "Fill Location"; fills only empty fields unless overwrite, one undo step, activity row for batches), metadata.downloadPlaceNames (background download of the CC-BY 4.0 GeoNames files into the settings folder, cancellable activity row) and metadata.placeNamesStatus. No data is bundled; NOTICE records the licence. Fixes #582. Sources: GeoNames dump readme (prose); haversine (textbook); PKWARE APPNOTE (ZIP, prose); cell index own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 9 + Cargo.toml | 1 + NOTICE | 7 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/geocode.rs | 272 +++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/geocode/Cargo.toml | 13 ++ crates/geocode/README.md | 17 ++ crates/geocode/src/lib.rs | 273 ++++++++++++++++++++++++++++ crates/geocode/src/unzip.rs | 122 +++++++++++++ crates/ui-egui/locales/de.json | 9 +- crates/ui-egui/locales/es.json | 9 +- crates/ui-egui/locales/fr.json | 9 +- crates/ui-egui/locales/ja.json | 9 +- crates/ui-egui/locales/pt-br.json | 9 +- crates/ui-egui/locales/ru.json | 9 +- crates/ui-egui/locales/uk.json | 9 +- crates/ui-egui/locales/zh-hans.json | 9 +- crates/ui-egui/locales/zh-hant.json | 9 +- crates/ui-egui/src/panels/right.rs | 7 + docs/parity-checklist.md | 10 +- xtask/src/layers.rs | 1 + 22 files changed, 802 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/cmd/geocode.rs create mode 100644 crates/geocode/Cargo.toml create mode 100644 crates/geocode/README.md create mode 100644 crates/geocode/src/lib.rs create mode 100644 crates/geocode/src/unzip.rs diff --git a/Cargo.lock b/Cargo.lock index 700f386fb..6f432b266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3210,6 +3210,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-faces", "lightcraft-fetch", + "lightcraft-geocode", "lightcraft-geom", "lightcraft-gpu", "lightcraft-heif", @@ -3257,6 +3258,14 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "lightcraft-geocode" +version = "0.6.0" +dependencies = [ + "miniz_oxide 0.8.9", + "serde", +] + [[package]] name = "lightcraft-geom" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index b9a511e26..7bcc0004d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ lightcraft-mcp = { path = "crates/mcp" } lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } +lightcraft-geocode = { path = "crates/geocode" } # foundation thiserror = "2" diff --git a/NOTICE b/NOTICE index 5fd60227f..f3adeff84 100644 --- a/NOTICE +++ b/NOTICE @@ -48,3 +48,10 @@ also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/mai (the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's licence as OFL-.txt. + +Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and +are never distributed with it. On the user's request (Photo > Download Place Names) the app +downloads cities15000.zip, admin1CodesASCII.txt and countryInfo.txt from GeoNames +(https://download.geonames.org/export/dump/) into the settings folder. GeoNames data is licensed +under Creative Commons Attribution 4.0 (https://creativecommons.org/licenses/by/4.0/); +attribution: "GeoNames, geonames.org". The lookup code (crates/geocode) is our own. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4036a5c94..2fb403cec 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -27,6 +27,7 @@ lightcraft-meta = { workspace = true } lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } +lightcraft-geocode = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs new file mode 100644 index 000000000..bf9389adf --- /dev/null +++ b/crates/engine/src/cmd/geocode.rs @@ -0,0 +1,272 @@ +//! Reverse geocoding (issue #582): fill City / State / Country from a photo's GPS position, offline, with the +//! GeoNames place names ([`lightcraft_geocode`]). The place names (CC-BY 4.0) are not shipped with the app: the user +//! downloads them once (`metadata.downloadPlaceNames`, ~3 MB from download.geonames.org) into the settings folder; +//! after that every lookup is local and no position ever leaves the machine. +//! +//! Sources: GeoNames dump readme (prose); own design. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use lightcraft_catalog::{Op, PhotoId}; +use lightcraft_geocode::{GEONAMES_ATTRIBUTION, GEONAMES_FILES, GeoNames, LatLon}; + +use super::{CommandSpec, always, bad, bool_or, cmd, has_selection, str_param}; +use crate::activity::Cancel; +use crate::{Result, Session}; + +/// Where the place names live: `dataset` (a folder) or `/geonames`. +fn dataset_dir(p: &Value) -> Option { + str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames"))) +} + +fn status(_s: &mut Session, p: &Value) -> Result { + let dir = dataset_dir(p); + let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len()); + Ok( + json!({"downloaded": size.is_some(), "bytes": size, "path": dir, "attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE}), + ) +} + +/// Which place fields to write: a field is filled when it is empty, or always with `overwrite`. +fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, overwrite: bool) { + for (field, value) in [ + (&mut meta.location, &place.sublocation), + (&mut meta.city, &place.city), + (&mut meta.state, &place.state), + (&mut meta.country, &place.country), + ] { + if !value.is_empty() && (overwrite || field.trim().is_empty()) { + *field = value.clone(); + } + } +} + +fn reverse(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.reverseGeocode"; + let overwrite = bool_or(p, "overwrite", false); + let ids = s.targets(p); + let todo: Vec<(PhotoId, LatLon)> = ids + .iter() + .filter_map(|id| { + let (la, lo) = s.catalog.photo(*id)?.meta.gps?; + let at = LatLon::new(la, lo); + at.is_valid().then_some((*id, at)) + }) + .collect(); + if todo.is_empty() { + return Err(bad(C, "none of these photos has a GPS position")); + } + let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?; + let names = GeoNames::load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?; + // a large selection shows in the activity stack and can be stopped + let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes)); + let mut ops = Vec::new(); + let mut found = Vec::new(); + for (n, (id, at)) in todo.iter().enumerate() { + if let Some(t) = &task { + if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) { + return Err(bad(C, "cancelled")); + } + t.progress(n as u64, todo.len() as u64); + } + let Some(place) = names.reverse(*at) else { continue }; + let Some(ph) = s.catalog.photo(*id) else { continue }; + let mut meta = ph.meta.clone(); + fill(&mut meta, &place, overwrite); + if meta != ph.meta { + ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) }); + } + found.push(json!({"id": id.0, "place": place})); + } + drop(task); + let changed = ops.len(); + if !bool_or(p, "dryRun", false) && changed > 0 { + s.commit("Reverse Geocode", Op::Batch { ops })?; + } + Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION})) +} + +#[cfg(not(target_arch = "wasm32"))] +mod download { + use std::path::Path; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + use lightcraft_geocode::{GEONAMES_BASE, GEONAMES_ZIP}; + + use super::*; + use crate::activity::Unit; + + const MAX_ZIP: u64 = 64 * 1024 * 1024; + const MAX_TEXT: usize = 256 * 1024 * 1024; + + fn get(name: &str, base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result<(), String> { + let _ = std::fs::remove_file(dir.join(name)); + let file = lightcraft_fetch::FileSpec { name, size: None, sha256: None, max: MAX_ZIP }; + lightcraft_fetch::download_url(&file, &format!("{base}{name}"), dir, &lightcraft_fetch::Options::default(), cancel, &mut |pr| { + task.progress(pr.done, pr.total.max(pr.done)) + }) + .map_err(|e| e.to_string()) + } + + /// Fetch and unpack the place names into `dir`; the new files replace the old ones only when complete. + pub(super) fn run(base: &str, dir: &Path, cancel: &AtomicBool, task: &crate::activity::TaskHandle) -> std::result::Result { + let staging = dir.join(".download"); + std::fs::create_dir_all(&staging).map_err(|e| format!("could not create {}: {e}", staging.display()))?; + get(GEONAMES_ZIP, base, &staging, cancel, task)?; + let zip = std::fs::read(staging.join(GEONAMES_ZIP)).map_err(|e| e.to_string())?; + let cities = lightcraft_geocode::unzip::extract(&zip, GEONAMES_FILES[0], MAX_TEXT).map_err(|e| e.to_string())?; + let places = GeoNames::parse(&String::from_utf8_lossy(&cities), None, None).len(); + if places == 0 { + return Err("the downloaded place list is empty".into()); + } + let _ = std::fs::remove_file(staging.join(GEONAMES_ZIP)); + let write = |name: &str, data: &[u8]| std::fs::write(dir.join(name), data).map_err(|e| format!("could not save {name}: {e}")); + for name in &GEONAMES_FILES[1..] { + // optional: without them states stay blank and countries are ISO codes + match get(name, base, &staging, cancel, task).and_then(|()| std::fs::read(staging.join(name)).map_err(|e| e.to_string())) { + Ok(b) => write(name, &b)?, + Err(e) => log::warn!("place names: {name}: {e}"), + } + let _ = std::fs::remove_file(staging.join(name)); + } + write(GEONAMES_FILES[0], &cities)?; + let _ = std::fs::remove_dir(&staging); + Ok(places) + } + + /// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable). + pub(super) fn start(s: &mut Session, p: &Value) -> Result { + const C: &str = "metadata.downloadPlaceNames"; + let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?; + let base = str_param(p, "base").unwrap_or(GEONAMES_BASE).to_string(); + if !(base.starts_with("https://") || base.starts_with("http://127.0.0.1")) { + return Err(bad(C, "`base` must be an https:// address")); + } + let cancel = Arc::new(AtomicBool::new(false)); + let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail("GeoNames"); + let handle = task.handle(); + let thread_dir = dir.clone(); + std::thread::Builder::new() + .name("place-names-download".into()) + .spawn(move || { + // the activity row goes when the thread ends, however it ends + let _task = task; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle))); + match outcome { + Ok(Ok(n)) => log::info!("place names: {n} places downloaded"), + Ok(Err(e)) => log::warn!("place names download failed: {e}"), + Err(_) => log::warn!("place names download stopped unexpectedly"), + } + }) + .map_err(|e| bad(C, format!("could not start the download: {e}")))?; + Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION})) + } +} + +pub fn specs() -> Vec { + #[allow(unused_mut)] + let mut v = vec![ + cmd!( + "metadata.reverseGeocode", + "Fill Location from GPS", + ["Photo"], + None, + "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step", + has_selection, + reverse + ), + cmd!(query "metadata.placeNamesStatus", "Place Names Status", [], None, "{dataset?: folder} → {downloaded, bytes, path, attribution, source}", always, status), + ]; + #[cfg(not(target_arch = "wasm32"))] + v.push(cmd!( + "metadata.downloadPlaceNames", + "Download Place Names (GeoNames)", + ["Photo"], + None, + "{dataset?: folder} → {started, path, attribution} — downloads the GeoNames cities list (CC-BY 4.0, ~3 MB, from download.geonames.org) in the background for Fill Location from GPS; no photo data is sent", + always, + download::start + )); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\ + 2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n"; + + fn dataset(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lightcraft-geocode-cmd-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[1]), "FR.11\tÎle-de-France\nGB.ENG\tEngland\n").unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[2]), "#c\nFR\tFRA\t250\tFR\tFrance\nGB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap(); + dir + } + + fn set_gps(s: &mut Session, id: PhotoId, gps: Option<(f64, f64)>, city: &str) { + let mut meta = s.catalog.photo(id).unwrap().meta.clone(); + meta.gps = gps; + meta.city = city.into(); + meta.state.clear(); + meta.country.clear(); + meta.location.clear(); + s.commit("test", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); + } + + #[test] + fn fills_empty_fields_keeps_typed_ones_and_undoes() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + let dir = dataset("fill"); + set_gps(&mut s, ids[0], Some((48.86, 2.34)), ""); + set_gps(&mut s, ids[1], Some((51.5, -0.1)), "Westminster"); + set_gps(&mut s, ids[2], Some((f64::NAN, 0.0)), ""); + set_gps(&mut s, ids[3], Some((0.0, 0.0)), ""); + let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0, ids[2].0, ids[3].0], "dataset": dir})).unwrap(); + assert_eq!((r["looked"].as_u64(), r["found"].as_u64(), r["changed"].as_u64()), (Some(3), Some(2), Some(2)), "{r}"); + let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone(); + assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("Paris", "Île-de-France", "France")); + let m1 = s.catalog.photo(ids[1]).unwrap().meta.clone(); + assert_eq!((m1.city.as_str(), m1.state.as_str(), m1.country.as_str()), ("Westminster", "England", "United Kingdom")); + assert!(s.activity.list().is_empty(), "the activity row ends with the command"); + // one undo step + s.undo_step().unwrap(); + assert!(s.catalog.photo(ids[0]).unwrap().meta.city.is_empty()); + assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Westminster"); + // overwrite replaces typed text + s.execute("metadata.reverseGeocode", &json!({"ids": [ids[1].0], "dataset": dir, "overwrite": true})).unwrap(); + assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "London"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn explains_missing_data_and_gps() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + set_gps(&mut s, ids[0], None, ""); + let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0]})).unwrap_err(); + assert!(e.to_string().contains("GPS"), "{e}"); + set_gps(&mut s, ids[0], Some((48.86, 2.34)), ""); + let missing = std::env::temp_dir().join(format!("lightcraft-geocode-none-{}", std::process::id())); + let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": missing})).unwrap_err(); + assert!(e.to_string().contains("download"), "{e}"); + let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": missing})).unwrap(); + assert_eq!(st["downloaded"], false); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn download_refuses_plain_http() { + let mut s = Session::with_demo(); + assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": "/nonexistent", "base": "http://example.com/"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7def13d3..9ecc9008b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +mod geocode; pub mod keywords; pub mod library; pub mod lut_profiles; @@ -150,6 +151,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(missing::specs()); v.extend(sync::specs()); v.extend(metadata::specs()); + v.extend(geocode::specs()); v.extend(filters::specs()); v.extend(denoise::specs()); v.extend(face_models::specs()); diff --git a/crates/geocode/Cargo.toml b/crates/geocode/Cargo.toml new file mode 100644 index 000000000..0d30af762 --- /dev/null +++ b/crates/geocode/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lightcraft-geocode" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft offline reverse geocoding: GPS position to sublocation / city / state / country from a GeoNames dump (L1)" + +[lints] +workspace = true + +[dependencies] +miniz_oxide = { workspace = true } +serde = { workspace = true } diff --git a/crates/geocode/README.md b/crates/geocode/README.md new file mode 100644 index 000000000..9e0376b0c --- /dev/null +++ b/crates/geocode/README.md @@ -0,0 +1,17 @@ +# lightcraft-geocode (L1) + +Offline reverse geocoding: a GPS position becomes Sublocation / City / State / Country. + +- **Data:** the GeoNames dump (), licensed CC-BY 4.0 + (attribution: "GeoNames, geonames.org"). Nothing is bundled: the engine + (`metadata.downloadPlaceNames`) downloads `cities15000.zip`, `admin1CodesASCII.txt` and + `countryInfo.txt` on the user's request into the settings folder. This crate only parses and + searches; it never touches the network. +- **Lookup:** the nearest populated place within `MAX_KM` (great-circle distance), searched in the + 1° cells around the position, so one lookup looks at a few dozen places, not all of them. +- **unzip:** just enough ZIP to read one file out of `cities15000.zip` (stored or deflated). +- **Never panics on hostile input:** bad lines are skipped, NaN / out-of-range positions find + nothing, sizes and counts are capped. + +Sources: GeoNames dump readme (file layout, prose); haversine distance (textbook); PKWARE APPNOTE +(ZIP, prose spec); the cell index is own design. diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs new file mode 100644 index 000000000..3acb6b5f6 --- /dev/null +++ b/crates/geocode/src/lib.rs @@ -0,0 +1,273 @@ +//! Offline reverse geocoding: a GPS position → Sublocation / City / State / Country, from the +//! GeoNames dump (CC-BY 4.0, "GeoNames, geonames.org"). See README.md. +//! +//! Sources: GeoNames dump readme (prose); haversine (textbook); cell index own design. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod unzip; + +use std::collections::HashMap; +use std::path::Path; + +use serde::Serialize; + +/// Lookups farther than this from any place find nothing. +pub const MAX_KM: f64 = 50.0; +/// The files the engine downloads (the first comes zipped as `cities15000.zip`). +pub const GEONAMES_FILES: [&str; 3] = ["cities15000.txt", "admin1CodesASCII.txt", "countryInfo.txt"]; +/// The zip holding [`GEONAMES_FILES`]`[0]`. +pub const GEONAMES_ZIP: &str = "cities15000.zip"; +pub const GEONAMES_BASE: &str = "https://download.geonames.org/export/dump/"; +pub const GEONAMES_ATTRIBUTION: &str = "Place names: GeoNames (geonames.org), CC-BY 4.0"; +/// Places kept from a dataset (cities15000 has ~33 000; cities500 ~200 000). +const MAX_PLACES: usize = 2_000_000; +/// Largest name kept (GeoNames names are short; anything longer is junk). +const MAX_NAME: usize = 200; +const EARTH_RADIUS_M: f64 = 6_371_008.8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GeocodeError { + /// The place names aren't there (not downloaded yet) or can't be read. + Missing(String), + /// The data is not what it should be. + Invalid(String), +} + +impl std::fmt::Display for GeocodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GeocodeError::Missing(e) | GeocodeError::Invalid(e) => f.write_str(e), + } + } +} + +impl std::error::Error for GeocodeError {} + +/// A position in degrees. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LatLon { + pub lat: f64, + pub lon: f64, +} + +impl LatLon { + pub fn new(lat: f64, lon: f64) -> LatLon { + LatLon { lat, lon } + } + + /// Finite and on the globe. + pub fn is_valid(self) -> bool { + self.lat.is_finite() && self.lon.is_finite() && self.lat.abs() <= 90.0 && self.lon.abs() <= 180.0 + } +} + +/// Great-circle distance in metres (haversine). NaN in, NaN out. +pub fn distance_m(a: LatLon, b: LatLon) -> f64 { + let (p1, p2) = (a.lat.to_radians(), b.lat.to_radians()); + let dp = p2 - p1; + let dl = (b.lon - a.lon).to_radians(); + let h = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin() +} + +/// What a lookup fills in (empty strings: unknown). +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct Place { + /// Below city level. GeoNames' city list has none, so offline lookups leave it empty. + pub sublocation: String, + pub city: String, + pub state: String, + pub country: String, + /// ISO 3166-1 alpha-2, upper case. + pub iso: String, + /// Metres from the position to the place. + pub distance: f64, +} + +#[derive(Clone, Debug)] +struct City { + name: String, + at: LatLon, + country: String, + admin1: String, +} + +/// An offline GeoNames index: places bucketed by 1° cells. +#[derive(Clone, Debug, Default)] +pub struct GeoNames { + cities: Vec, + cells: HashMap<(i32, i32), Vec>, + admin1: HashMap, + countries: HashMap, +} + +fn cell(p: LatLon) -> (i32, i32) { + // valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range + (p.lat.floor() as i32, p.lon.floor() as i32) +} + +fn short(s: &str) -> String { + s.trim().chars().take(MAX_NAME).collect() +} + +impl GeoNames { + /// From the dump's text: `cities` (tab-separated, the `cities*.txt` layout), and optionally + /// `admin1CodesASCII.txt` and `countryInfo.txt`. Bad lines are skipped. + pub fn parse(cities: &str, admin1: Option<&str>, countries: Option<&str>) -> GeoNames { + let mut g = GeoNames::default(); + for line in cities.lines() { + if g.cities.len() >= MAX_PLACES { + break; + } + let f: Vec<&str> = line.split('\t').collect(); + // columns: 1 name, 4 latitude, 5 longitude, 8 country code, 10 admin1 code + let (Some(name), Some(lat), Some(lon), Some(cc)) = (f.get(1), f.get(4), f.get(5), f.get(8)) else { continue }; + let (Ok(lat), Ok(lon)) = (lat.trim().parse::(), lon.trim().parse::()) else { continue }; + let at = LatLon::new(lat, lon); + let name = short(name); + if !at.is_valid() || name.is_empty() { + continue; + } + let Ok(i) = u32::try_from(g.cities.len()) else { break }; + g.cells.entry(cell(at)).or_default().push(i); + g.cities.push(City { name, at, country: short(cc).to_ascii_uppercase(), admin1: f.get(10).map(|s| short(s)).unwrap_or_default() }); + } + for line in admin1.unwrap_or("").lines() { + let mut f = line.split('\t'); + if let (Some(code), Some(name)) = (f.next(), f.next()) { + g.admin1.insert(short(code), short(name)); + } + } + for line in countries.unwrap_or("").lines().filter(|l| !l.starts_with('#')) { + let f: Vec<&str> = line.split('\t').collect(); + if let (Some(iso), Some(name)) = (f.first(), f.get(4)) { + g.countries.insert(short(iso).to_ascii_uppercase(), short(name)); + } + } + g + } + + /// Load from a folder holding [`GEONAMES_FILES`] (the last two are optional: without them + /// states stay blank and countries are ISO codes). + pub fn load(dir: &Path) -> Result { + let read = |n: &str| std::fs::read(dir.join(n)).map(|b| String::from_utf8_lossy(&b).into_owned()); + let cities = read(GEONAMES_FILES[0]).map_err(|e| GeocodeError::Missing(format!("the place names aren't downloaded ({e})")))?; + let g = GeoNames::parse(&cities, read(GEONAMES_FILES[1]).ok().as_deref(), read(GEONAMES_FILES[2]).ok().as_deref()); + if g.is_empty() { + return Err(GeocodeError::Invalid("the place names file holds no places".into())); + } + Ok(g) + } + + pub fn len(&self) -> usize { + self.cities.len() + } + + pub fn is_empty(&self) -> bool { + self.cities.is_empty() + } + + /// The nearest place within [`MAX_KM`]; `None` for an invalid position or open country. + pub fn reverse(&self, p: LatLon) -> Option { + if !p.is_valid() { + return None; + } + let (cy, cx) = cell(p); + let mut best: Option<(f64, &City)> = None; + // ±1° covers 50 km everywhere except very near the poles (where there are no cities) + for dy in -1..=1 { + for dx in -1..=1 { + let mut x = cx + dx; + if x < -180 { + x += 360; + } else if x >= 180 { + x -= 360; + } + for &i in self.cells.get(&(cy + dy, x)).into_iter().flatten() { + let Some(c) = self.cities.get(i as usize) else { continue }; + let d = distance_m(p, c.at); + if best.is_none_or(|(b, _)| d < b) { + best = Some((d, c)); + } + } + } + } + let (d, c) = best?; + (d <= MAX_KM * 1000.0).then(|| Place { + sublocation: String::new(), + city: c.name.clone(), + state: self.admin1.get(&format!("{}.{}", c.country, c.admin1)).cloned().unwrap_or_default(), + country: self.countries.get(&c.country).cloned().unwrap_or_else(|| c.country.clone()), + iso: c.country.clone(), + distance: d.round(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\ + 2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n\ + bad line\n\ + 1\tNowhere\tx\t\tNaN\t0\tP\tP\tXX\n\ + 2\tFar\tx\t\t500\t0\tP\tP\tXX\n\ + 3\tInf\tx\t\tinf\t-inf\tP\tP\tXX\n"; + + #[test] + fn nearest_place() { + let g = GeoNames::parse(CITIES, Some("FR.11\tÎle-de-France\nGB.ENG\tEngland\n"), Some("#ISO\tx\nFR\tFRA\t250\tFR\tFrance\n")); + assert_eq!(g.len(), 2); + let p = g.reverse(LatLon::new(48.86, 2.34)).unwrap(); + assert_eq!((p.city.as_str(), p.state.as_str(), p.country.as_str(), p.iso.as_str()), ("Paris", "Île-de-France", "France", "FR")); + assert!(p.distance > 0.0 && p.distance < 2000.0); + // no countryInfo row: the ISO code stands in + let l = g.reverse(LatLon::new(51.5, -0.1)).unwrap(); + assert_eq!((l.city.as_str(), l.state.as_str(), l.country.as_str()), ("London", "England", "GB")); + // open sea + assert!(g.reverse(LatLon::new(0.0, 0.0)).is_none()); + } + + #[test] + fn hostile_positions_find_nothing() { + let g = GeoNames::parse(CITIES, None, None); + for (la, lo) in [(f64::NAN, 0.0), (0.0, f64::NAN), (f64::INFINITY, 0.0), (91.0, 0.0), (0.0, -180.5), (-1e300, 1e300)] { + assert!(g.reverse(LatLon::new(la, lo)).is_none(), "{la}, {lo}"); + } + // the poles and the antimeridian are fine + assert!(g.reverse(LatLon::new(90.0, 180.0)).is_none()); + assert!(g.reverse(LatLon::new(-90.0, -180.0)).is_none()); + assert!(GeoNames::default().reverse(LatLon::new(48.86, 2.34)).is_none()); + } + + #[test] + fn across_the_antimeridian() { + let fiji = GeoNames::parse("1\tX\tX\t\t-17.0\t179.9\tP\tP\tFJ\t\t\n", None, None); + assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); + } + + #[test] + fn junk_text_parses_to_nothing() { + let g = GeoNames::parse("\t\t\t\t\t\n\u{0}\t\u{feff}\n", Some("\n\t\n"), Some("x")); + assert!(g.is_empty()); + let long = format!("1\t{}\tx\t\t1\t1\tP\tP\tXX\n", "a".repeat(10_000)); + assert_eq!(GeoNames::parse(&long, None, None).reverse(LatLon::new(1.0, 1.0)).unwrap().city.len(), MAX_NAME); + } + + #[test] + fn load_from_a_folder() { + let dir = std::env::temp_dir().join(format!("lightcraft-geocode-load-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Missing(_)))); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[0]), "junk").unwrap(); + assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Invalid(_)))); + std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap(); + std::fs::write(dir.join(GEONAMES_FILES[2]), "GB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap(); + let g = GeoNames::load(&dir).unwrap(); + assert_eq!(g.reverse(LatLon::new(51.5, -0.1)).unwrap().country, "United Kingdom"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/geocode/src/unzip.rs b/crates/geocode/src/unzip.rs new file mode 100644 index 000000000..18e127181 --- /dev/null +++ b/crates/geocode/src/unzip.rs @@ -0,0 +1,122 @@ +//! Just enough ZIP to read one file out of a GeoNames dump (`cities15000.zip`): the central +//! directory, stored or deflated entries, sizes capped. +//! +//! Sources: PKWARE APPNOTE (prose spec); own design. + +use crate::GeocodeError; + +fn u16_at(b: &[u8], i: usize) -> Option { + let s = b.get(i..i.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +fn u32_at(b: &[u8], i: usize) -> Option { + let s = b.get(i..i.checked_add(4)?)?; + Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])) +} + +fn bad(what: &str) -> GeocodeError { + GeocodeError::Invalid(format!("not a readable ZIP archive ({what})")) +} + +/// The contents of the entry whose name ends with `name`, at most `max` bytes. +pub fn extract(zip: &[u8], name: &str, max: usize) -> Result, GeocodeError> { + // end of central directory: the last "PK\x05\x06" within the final 64 KiB + 22 bytes + let from = zip.len().saturating_sub(65_557); + let eocd = (from..zip.len().saturating_sub(21)) + .rev() + .find(|&i| zip.get(i..i.saturating_add(4)) == Some(b"PK\x05\x06")) + .ok_or_else(|| bad("no directory"))?; + let field16 = |at: usize, what: &str| u16_at(zip, at).map(usize::from).ok_or_else(|| bad(what)); + let field32 = |at: usize, what: &str| u32_at(zip, at).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(what)); + let count = field16(eocd.saturating_add(10), "directory")?; + let mut p = field32(eocd.saturating_add(16), "directory")?; + for _ in 0..count { + if zip.get(p..p.saturating_add(4)) != Some(b"PK\x01\x02") { + return Err(bad("directory entry")); + } + let method = field16(p.saturating_add(10), "entry")?; + let csize = field32(p.saturating_add(20), "entry")?; + let usize_ = field32(p.saturating_add(24), "entry")?; + let nlen = field16(p.saturating_add(28), "entry")?; + let xlen = field16(p.saturating_add(30), "entry")?; + let clen = field16(p.saturating_add(32), "entry")?; + let local = field32(p.saturating_add(42), "entry")?; + let name_at = p.saturating_add(46); + let entry = zip.get(name_at..name_at.saturating_add(nlen)).ok_or_else(|| bad("entry name"))?; + p = name_at.saturating_add(nlen).saturating_add(xlen).saturating_add(clen); + if !String::from_utf8_lossy(entry).ends_with(name) { + continue; + } + if usize_ > max { + return Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))); + } + if zip.get(local..local.saturating_add(4)) != Some(b"PK\x03\x04") { + return Err(bad("local header")); + } + let lnlen = field16(local.saturating_add(26), "local header")?; + let lxlen = field16(local.saturating_add(28), "local header")?; + let start = local.saturating_add(30).saturating_add(lnlen).saturating_add(lxlen); + let data = zip.get(start..start.saturating_add(csize)).ok_or_else(|| bad("truncated"))?; + return match method { + 0 if data.len() <= max => Ok(data.to_vec()), + 0 => Err(GeocodeError::Invalid(format!("{name} is larger than {max} bytes"))), + 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, max).map_err(|e| bad(&format!("deflate: {e:?}"))), + m => Err(bad(&format!("compression method {m}"))), + }; + } + Err(GeocodeError::Invalid(format!("{name} is not in the archive"))) +} + +#[cfg(test)] +pub(crate) fn make(name: &str, data: &[u8]) -> Vec { + let c = miniz_oxide::deflate::compress_to_vec(data, 6); + let mut z = Vec::new(); + z.extend_from_slice(b"PK\x03\x04"); + z.extend_from_slice(&[20, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + z.extend_from_slice(&(c.len() as u32).to_le_bytes()); + z.extend_from_slice(&(data.len() as u32).to_le_bytes()); + z.extend_from_slice(&(name.len() as u16).to_le_bytes()); + z.extend_from_slice(&[0, 0]); + z.extend_from_slice(name.as_bytes()); + z.extend_from_slice(&c); + let cd = z.len(); + z.extend_from_slice(b"PK\x01\x02"); + z.extend_from_slice(&[20, 0, 20, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + z.extend_from_slice(&(c.len() as u32).to_le_bytes()); + z.extend_from_slice(&(data.len() as u32).to_le_bytes()); + z.extend_from_slice(&(name.len() as u16).to_le_bytes()); + z.extend_from_slice(&[0; 12]); + z.extend_from_slice(&0u32.to_le_bytes()); + z.extend_from_slice(name.as_bytes()); + let cdlen = z.len() - cd; + z.extend_from_slice(b"PK\x05\x06"); + z.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]); + z.extend_from_slice(&(cdlen as u32).to_le_bytes()); + z.extend_from_slice(&(cd as u32).to_le_bytes()); + z.extend_from_slice(&[0, 0]); + z +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_junk() { + let z = make("cities15000.txt", b"hello world"); + assert_eq!(extract(&z, "cities15000.txt", 100).unwrap(), b"hello world"); + assert!(extract(&z, "cities15000.txt", 5).is_err()); + assert!(extract(&z, "other.txt", 100).is_err()); + assert!(extract(b"PK", "x", 10).is_err()); + assert!(extract(b"", "x", 10).is_err()); + for cut in 0..z.len() { + let _ = extract(&z[..cut], "cities15000.txt", 100); + } + for i in 0..z.len() { + let mut bad = z.clone(); + bad[i] ^= 0xff; + let _ = extract(&bad, "cities15000.txt", 100); + } + } +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 6520fb3e3..fbecd6e68 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2039,5 +2039,12 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "Fill Location from GPS": "Ort aus GPS ausfüllen", + "Download Place Names (GeoNames)": "Ortsnamen herunterladen (GeoNames)", + "Place Names Status": "Status der Ortsnamen", + "Fill Location": "Ort ausfüllen", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Leere Felder Stadt, Bundesland und Land aus GPS ausfüllen (GeoNames-Ortsnamen, offline)", + "Looking up places": "Orte werden nachgeschlagen", + "Downloading place names": "Ortsnamen werden heruntergeladen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fc53b6392..36d041a73 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1884,5 +1884,12 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "Fill Location from GPS": "Rellenar ubicación desde GPS", + "Download Place Names (GeoNames)": "Descargar nombres de lugares (GeoNames)", + "Place Names Status": "Estado de los nombres de lugares", + "Fill Location": "Rellenar ubicación", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Rellenar Ciudad, Estado y País vacíos desde el GPS (nombres de lugares de GeoNames, sin conexión)", + "Looking up places": "Buscando lugares", + "Downloading place names": "Descargando nombres de lugares" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 53509a503..16bbc30d6 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1884,5 +1884,12 @@ "Panels": "Panels", "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels" + "Resize panels": "Resize panels", + "Fill Location from GPS": "Remplir le lieu depuis le GPS", + "Download Place Names (GeoNames)": "Télécharger les noms de lieux (GeoNames)", + "Place Names Status": "État des noms de lieux", + "Fill Location": "Remplir le lieu", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Remplir Ville, État et Pays vides depuis le GPS (noms de lieux GeoNames, hors ligne)", + "Looking up places": "Recherche des lieux", + "Downloading place names": "Téléchargement des noms de lieux" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2babd30b6..ce7bda184 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1886,5 +1886,12 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "Fill Location from GPS": "GPS から場所を入力", + "Download Place Names (GeoNames)": "地名データをダウンロード (GeoNames)", + "Place Names Status": "地名データの状態", + "Fill Location": "場所を入力", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "空の市区町村・都道府県・国を GPS から入力 (GeoNames の地名、オフライン)", + "Looking up places": "場所を検索中", + "Downloading place names": "地名データをダウンロード中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dfe472818..59d8b2c62 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1884,5 +1884,12 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "Fill Location from GPS": "Preencher local pelo GPS", + "Download Place Names (GeoNames)": "Baixar nomes de lugares (GeoNames)", + "Place Names Status": "Status dos nomes de lugares", + "Fill Location": "Preencher local", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Preencher Cidade, Estado e País vazios pelo GPS (nomes de lugares GeoNames, offline)", + "Looking up places": "Procurando lugares", + "Downloading place names": "Baixando nomes de lugares" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fafb65a14..11a1878ef 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1598,5 +1598,12 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "Fill Location from GPS": "Заполнить место по GPS", + "Download Place Names (GeoNames)": "Загрузить названия мест (GeoNames)", + "Place Names Status": "Состояние названий мест", + "Fill Location": "Заполнить место", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Заполнить пустые поля «Город», «Регион» и «Страна» по GPS (названия мест GeoNames, офлайн)", + "Looking up places": "Поиск мест", + "Downloading place names": "Загрузка названий мест" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 7ebad5b10..ce8713f69 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2078,5 +2078,12 @@ "Panels": "Panels", "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels" + "Resize panels": "Resize panels", + "Fill Location from GPS": "Заповнити місце за GPS", + "Download Place Names (GeoNames)": "Завантажити назви місць (GeoNames)", + "Place Names Status": "Стан назв місць", + "Fill Location": "Заповнити місце", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Заповнити порожні поля «Місто», «Регіон» і «Країна» за GPS (назви місць GeoNames, офлайн)", + "Looking up places": "Пошук місць", + "Downloading place names": "Завантаження назв місць" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8a0e58b8e..805330bf6 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1884,5 +1884,12 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "Fill Location from GPS": "根据 GPS 填写位置", + "Download Place Names (GeoNames)": "下载地名数据 (GeoNames)", + "Place Names Status": "地名数据状态", + "Fill Location": "填写位置", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "根据 GPS 填写空白的城市、省/州和国家(GeoNames 地名,离线)", + "Looking up places": "正在查找地点", + "Downloading place names": "正在下载地名数据" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f1cbec3a0..0810fc2db 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1884,5 +1884,12 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "Fill Location from GPS": "根據 GPS 填寫位置", + "Download Place Names (GeoNames)": "下載地名資料 (GeoNames)", + "Place Names Status": "地名資料狀態", + "Fill Location": "填寫位置", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "根據 GPS 填寫空白的城市、州/省和國家(GeoNames 地名,離線)", + "Looking up places": "正在查找地點", + "Downloading place names": "正在下載地名資料" } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 1c5a04ad9..6b49fd891 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -668,6 +668,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast(ui.ctx(), e); } } + if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) + .on_hover_text(crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)")) + .clicked() + && let Err(e) = app.run("metadata.reverseGeocode", json!({})) + { + app.toast(ui.ctx(), e); + } }); } if let Some(a) = p.analysis { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..aac80c529 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | -| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 33 | 16 | 35 | 8 | — | 23/25 (92%) | +| **Total** | 397 | 50 | 73 | 35 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 520 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.3% of 164. ## Top gaps and shortcut notes @@ -164,7 +164,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | 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, copyright status, rights usage terms, copyright info URL, 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`, `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-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | 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; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), 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` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510) | | 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 | @@ -716,7 +716,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | | LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module) | | LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | | -| LRC-MAP-REVGEO | Reverse geocoding | OOS | 🚫 | | | +| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | `cmd:metadata.reverseGeocode` | offline from GeoNames (City / State / Country, no sublocation); no online provider | | LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | | | LRC-BOOK-AUTOLAYOUT | Book auto layout | P2 | ⬜ | | | | LRC-BOOK-PAGE | Book pages & templates | P2 | ⬜ | | | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 4443cb230..365384f19 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -47,6 +47,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("meta", Class::Layer(1)), ("develop", Class::Layer(1)), ("faces", Class::Layer(1)), + ("geocode", Class::Layer(1)), ("scenes", Class::Layer(1)), ("pipeline", Class::Layer(2)), ("gpu", Class::Layer(3)), From 23e046daba268f9414ba96d67f2c142b848698af Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 21:09:53 +1100 Subject: [PATCH 2/2] Reverse geocoding: surface download failures, cache the gazetteer, e2e download test - A failed, cancelled or finished place-names download is reported once as a toast (failures as an error toast that says what to do), and metadata.placeNamesStatus reports downloading / lastDownload / lastError. - The parsed GeoNames data is cached in the session and re-read only when the cities file's size or modification time changes. - End-to-end download test against a fake HTTP server on 127.0.0.1: a synthetic zip, a garbage zip, a truncated body and 404s. - Info panel: Fill Location gets its own row (it was clipped beside Show on Map in a narrow panel). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/geocode.rs | 224 +++++++++++++++++++++++++--- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/lib.rs | 3 + crates/geocode/src/unzip.rs | 5 +- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/lib.rs | 13 ++ crates/ui-egui/src/panels/right.rs | 14 +- 15 files changed, 257 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index bf9389adf..21b92c6e4 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -5,7 +5,9 @@ //! //! Sources: GeoNames dump readme (prose); own design. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::SystemTime; use serde_json::{Value, json}; @@ -21,12 +23,79 @@ fn dataset_dir(p: &Value) -> Option { str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames"))) } -fn status(_s: &mut Session, p: &Value) -> Result { +/// How a place-names download ended. +#[derive(Clone, Debug, PartialEq)] +pub enum DownloadOutcome { + /// Done: this many places. + Done(usize), + /// Failed, in words the user can act on. + Failed(String), + Cancelled, +} + +#[derive(Default)] +struct DownloadShared { + running: bool, + last: Option, + /// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]). + unreported: Option, +} + +/// The session's place names: the parsed gazetteer (kept while its file is unchanged) and the download state. +#[derive(Default)] +pub struct PlaceNames { + shared: Arc>, + /// (cities file, its size, its modification time, the parsed names). + cache: Option<(PathBuf, u64, Option, Arc)>, +} + +impl PlaceNames { + fn lock(&self) -> std::sync::MutexGuard<'_, DownloadShared> { + self.shared.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A download that ended since the last call (the UI shows it once: an error stays readable as a toast). + pub fn take_outcome(&self) -> Option { + self.lock().unreported.take() + } + + pub fn downloading(&self) -> bool { + self.lock().running + } + + /// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes. + fn load(&mut self, dir: &Path) -> std::result::Result, lightcraft_geocode::GeocodeError> { + let file = dir.join(GEONAMES_FILES[0]); + let meta = std::fs::metadata(&file).ok(); + let (len, mtime) = (meta.as_ref().map_or(0, |m| m.len()), meta.as_ref().and_then(|m| m.modified().ok())); + if let Some((f, l, t, g)) = &self.cache + && meta.is_some() + && *f == file + && *l == len + && *t == mtime + { + return Ok(g.clone()); + } + let g = Arc::new(GeoNames::load(dir)?); + self.cache = Some((file, len, mtime, g.clone())); + Ok(g) + } +} + +fn status(s: &mut Session, p: &Value) -> Result { let dir = dataset_dir(p); let size = dir.as_ref().and_then(|d| std::fs::metadata(d.join(GEONAMES_FILES[0])).ok()).map(|m| m.len()); - Ok( - json!({"downloaded": size.is_some(), "bytes": size, "path": dir, "attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE}), - ) + let sh = s.place_names.lock(); + let (last, error) = match &sh.last { + None => (Value::Null, Value::Null), + Some(DownloadOutcome::Done(n)) => (json!({"places": n}), Value::Null), + Some(DownloadOutcome::Failed(e)) => (json!("failed"), json!(e)), + Some(DownloadOutcome::Cancelled) => (json!("cancelled"), Value::Null), + }; + Ok(json!({ + "downloaded": size.is_some(), "bytes": size, "path": dir, "downloading": sh.running, "lastDownload": last, "lastError": error, + "attribution": GEONAMES_ATTRIBUTION, "source": lightcraft_geocode::GEONAMES_BASE, + })) } /// Which place fields to write: a field is filled when it is empty, or always with `overwrite`. @@ -59,7 +128,7 @@ fn reverse(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "none of these photos has a GPS position")); } let dir = dataset_dir(p).ok_or_else(|| bad(C, "no settings folder: give `dataset`"))?; - let names = GeoNames::load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?; + let names = s.place_names.load(&dir).map_err(|e| bad(C, format!("{e}: download the place names first (Photo ▸ Download Place Names)")))?; // a large selection shows in the activity stack and can be stopped let task = (todo.len() > 1).then(|| s.activity.start("geocode", "Looking up places", Cancel::Yes)); let mut ops = Vec::new(); @@ -145,25 +214,43 @@ mod download { if !(base.starts_with("https://") || base.starts_with("http://127.0.0.1")) { return Err(bad(C, "`base` must be an https:// address")); } + let shared = s.place_names.shared.clone(); + { + let mut sh = s.place_names.lock(); + if sh.running { + return Err(bad(C, "the place names are already downloading")); + } + sh.running = true; + } let cancel = Arc::new(AtomicBool::new(false)); let task = s.activity.start("download", "Downloading place names", Cancel::Flag(cancel.clone())); task.set_unit(Unit::Bytes); task.detail("GeoNames"); let handle = task.handle(); let thread_dir = dir.clone(); - std::thread::Builder::new() - .name("place-names-download".into()) - .spawn(move || { - // the activity row goes when the thread ends, however it ends - let _task = task; - let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle))); - match outcome { - Ok(Ok(n)) => log::info!("place names: {n} places downloaded"), - Ok(Err(e)) => log::warn!("place names download failed: {e}"), - Err(_) => log::warn!("place names download stopped unexpectedly"), - } - }) - .map_err(|e| bad(C, format!("could not start the download: {e}")))?; + let thread_shared = shared.clone(); + let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || { + // the activity row goes when the thread ends, however it ends + let _task = task; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle))); + let outcome = match outcome { + Ok(Ok(n)) => DownloadOutcome::Done(n), + _ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled, + Ok(Err(e)) => DownloadOutcome::Failed(format!("Could not download the place names: {e}. Check the connection and try again.")), + Err(_) => DownloadOutcome::Failed("The place names download stopped unexpectedly. Try again.".into()), + }; + if let DownloadOutcome::Failed(e) = &outcome { + log::warn!("{e}"); + } + let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner); + sh.running = false; + sh.last = Some(outcome.clone()); + sh.unreported = Some(outcome); + }); + if let Err(e) = spawned { + shared.lock().unwrap_or_else(PoisonError::into_inner).running = false; + return Err(bad(C, format!("could not start the download: {e}"))); + } Ok(json!({"started": true, "path": dir, "attribution": GEONAMES_ATTRIBUTION})) } } @@ -263,10 +350,105 @@ mod tests { assert_eq!(st["downloaded"], false); } + #[test] + fn the_gazetteer_is_cached_until_its_file_changes() { + let dir = dataset("cache"); + let mut names = PlaceNames::default(); + let a = names.load(&dir).unwrap(); + assert!(Arc::ptr_eq(&a, &names.load(&dir).unwrap()), "unchanged file: no re-read"); + // a different size invalidates it (a new download) + std::fs::write(dir.join(GEONAMES_FILES[0]), "1\tRome\tRome\t\t41.9\t12.5\tP\tP\tIT\t\t07\n").unwrap(); + let b = names.load(&dir).unwrap(); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(b.reverse(LatLon::new(41.9, 12.5)).unwrap().city, "Rome"); + std::fs::remove_file(dir.join(GEONAMES_FILES[0])).unwrap(); + assert!(names.load(&dir).is_err(), "a deleted file is not served from the cache"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A local HTTP server; the first path segment picks the behaviour: `good` (the synthetic dump), `garbage` (a zip + /// that isn't one), `short` (the body ends before its Content-Length), anything else 404. + #[cfg(not(target_arch = "wasm32"))] + fn serve() -> String { + use std::io::{BufRead, BufReader, Write}; + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let zip = lightcraft_geocode::unzip::make(GEONAMES_FILES[0], CITIES.as_bytes()); + std::thread::spawn(move || { + for conn in l.incoming() { + let Ok(mut c) = conn else { continue }; + let mut r = BufReader::new(c.try_clone().unwrap()); + let mut line = String::new(); + let _ = r.read_line(&mut line); + loop { + let mut h = String::new(); + if r.read_line(&mut h).unwrap_or(0) == 0 || h.trim().is_empty() { + break; + } + } + let path = line.split(' ').nth(1).unwrap_or("").to_string(); + let (status, body, len): (&str, Vec, Option) = match (path.split('/').nth(1).unwrap_or(""), path.ends_with(".zip")) { + ("good", true) => ("200 OK", zip.clone(), None), + ("good", false) if path.contains("countryInfo") => ("200 OK", b"FR\tFRA\t250\tFR\tFrance\n".to_vec(), None), + ("garbage", true) => ("200 OK", b"not a zip".to_vec(), None), + ("short", true) => ("200 OK", zip[..zip.len() / 2].to_vec(), Some(zip.len())), + _ => ("404 Not Found", b"nope".to_vec(), None), + }; + let n = len.unwrap_or(body.len()); + let _ = c.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {n}\r\nConnection: close\r\n\r\n").as_bytes()); + let _ = c.write_all(&body); + } + }); + format!("http://127.0.0.1:{port}") + } + + #[cfg(not(target_arch = "wasm32"))] + fn download(s: &mut Session, base: &str, dir: &Path) -> DownloadOutcome { + s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": base})).unwrap(); + assert!(s.place_names.downloading() || s.place_names.lock().unreported.is_some()); + let started = std::time::Instant::now(); + loop { + if let Some(o) = s.place_names.take_outcome() { + // the activity row ends with the thread + while !s.activity.list().is_empty() && started.elapsed().as_secs() < 30 { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(s.activity.list().is_empty()); + return o; + } + assert!(started.elapsed().as_secs() < 60, "the download never ended"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] - fn download_refuses_plain_http() { + fn download_end_to_end_and_its_failures() { let mut s = Session::with_demo(); - assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": "/nonexistent", "base": "http://example.com/"})).is_err()); + let ids = s.visible_cloned(); + let base = serve(); + let dir = std::env::temp_dir().join(format!("lightcraft-geocode-dl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + assert!(s.execute("metadata.downloadPlaceNames", &json!({"dataset": dir, "base": "http://example.com/"})).is_err()); + + for bad in ["missing", "garbage", "short"] { + let o = download(&mut s, &format!("{base}/{bad}/"), &dir); + let DownloadOutcome::Failed(e) = o else { panic!("{bad}: {o:?}") }; + assert!(e.contains("try again"), "{bad}: {e}"); + let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap(); + assert_eq!((st["downloaded"].as_bool(), st["lastDownload"].as_str()), (Some(false), Some("failed")), "{bad}: {st}"); + assert_eq!(st["lastError"].as_str(), Some(e.as_str())); + assert!(s.place_names.take_outcome().is_none(), "reported once"); + } + + assert_eq!(download(&mut s, &format!("{base}/good/"), &dir), DownloadOutcome::Done(2)); + let st = s.execute("metadata.placeNamesStatus", &json!({"dataset": dir})).unwrap(); + assert_eq!((st["downloaded"].as_bool(), st["lastError"].is_null(), st["lastDownload"]["places"].as_u64()), (Some(true), true, Some(2))); + set_gps(&mut s, ids[0], Some((48.86, 2.34)), ""); + s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap(); + let m = s.catalog.photo(ids[0]).unwrap().meta.clone(); + // admin1 was a 404 (optional): the state stays blank, the rest is filled + assert_eq!((m.city.as_str(), m.state.as_str(), m.country.as_str()), ("Paris", "", "France")); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9ecc9008b..a267b05b3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,7 +19,7 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; -mod geocode; +pub mod geocode; pub mod keywords; pub mod library; pub mod lut_profiles; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66acfe4a7..d021b1439 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -184,6 +184,8 @@ pub struct Session { pub face_models_dir: Option, /// Face model downloads started this session (the staged files wait in `/.downloads`). pub face_downloads: face_download::Downloads, + /// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`). + pub place_names: cmd::geocode::PlaceNames, /// The user's own list of models to download (`catalog.json` in the models folder), as last read. pub(crate) face_catalog: lightcraft_faces::catalog::Catalog, /// The loaded recognition model and the face embeddings made with it. @@ -310,6 +312,7 @@ impl Session { denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), + place_names: Default::default(), face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), diff --git a/crates/geocode/src/unzip.rs b/crates/geocode/src/unzip.rs index 18e127181..f78648d83 100644 --- a/crates/geocode/src/unzip.rs +++ b/crates/geocode/src/unzip.rs @@ -68,8 +68,9 @@ pub fn extract(zip: &[u8], name: &str, max: usize) -> Result, GeocodeErr Err(GeocodeError::Invalid(format!("{name} is not in the archive"))) } -#[cfg(test)] -pub(crate) fn make(name: &str, data: &[u8]) -> Vec { +/// A one-file deflated ZIP (test fixtures: the engine's download test serves one). +#[doc(hidden)] +pub fn make(name: &str, data: &[u8]) -> Vec { let c = miniz_oxide::deflate::compress_to_vec(data, 6); let mut z = Vec::new(); z.extend_from_slice(b"PK\x03\x04"); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index fbecd6e68..4d511be64 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2046,5 +2046,7 @@ "Fill Location": "Ort ausfüllen", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Leere Felder Stadt, Bundesland und Land aus GPS ausfüllen (GeoNames-Ortsnamen, offline)", "Looking up places": "Orte werden nachgeschlagen", - "Downloading place names": "Ortsnamen werden heruntergeladen" + "Downloading place names": "Ortsnamen werden heruntergeladen", + "Place names downloaded": "Ortsnamen heruntergeladen", + "Place names download cancelled": "Download der Ortsnamen abgebrochen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 36d041a73..3c6285419 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1891,5 +1891,7 @@ "Fill Location": "Rellenar ubicación", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Rellenar Ciudad, Estado y País vacíos desde el GPS (nombres de lugares de GeoNames, sin conexión)", "Looking up places": "Buscando lugares", - "Downloading place names": "Descargando nombres de lugares" + "Downloading place names": "Descargando nombres de lugares", + "Place names downloaded": "Nombres de lugares descargados", + "Place names download cancelled": "Descarga de nombres de lugares cancelada" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 16bbc30d6..053df242a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1891,5 +1891,7 @@ "Fill Location": "Remplir le lieu", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Remplir Ville, État et Pays vides depuis le GPS (noms de lieux GeoNames, hors ligne)", "Looking up places": "Recherche des lieux", - "Downloading place names": "Téléchargement des noms de lieux" + "Downloading place names": "Téléchargement des noms de lieux", + "Place names downloaded": "Noms de lieux téléchargés", + "Place names download cancelled": "Téléchargement des noms de lieux annulé" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ce7bda184..916bdc50c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1893,5 +1893,7 @@ "Fill Location": "場所を入力", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "空の市区町村・都道府県・国を GPS から入力 (GeoNames の地名、オフライン)", "Looking up places": "場所を検索中", - "Downloading place names": "地名データをダウンロード中" + "Downloading place names": "地名データをダウンロード中", + "Place names downloaded": "地名データをダウンロードしました", + "Place names download cancelled": "地名データのダウンロードを中止しました" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 59d8b2c62..2ae5f108d 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1891,5 +1891,7 @@ "Fill Location": "Preencher local", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Preencher Cidade, Estado e País vazios pelo GPS (nomes de lugares GeoNames, offline)", "Looking up places": "Procurando lugares", - "Downloading place names": "Baixando nomes de lugares" + "Downloading place names": "Baixando nomes de lugares", + "Place names downloaded": "Nomes de lugares baixados", + "Place names download cancelled": "Download dos nomes de lugares cancelado" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 11a1878ef..562b1c6f3 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1605,5 +1605,7 @@ "Fill Location": "Заполнить место", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Заполнить пустые поля «Город», «Регион» и «Страна» по GPS (названия мест GeoNames, офлайн)", "Looking up places": "Поиск мест", - "Downloading place names": "Загрузка названий мест" + "Downloading place names": "Загрузка названий мест", + "Place names downloaded": "Названия мест загружены", + "Place names download cancelled": "Загрузка названий мест отменена" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index ce8713f69..fd506d14c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2085,5 +2085,7 @@ "Fill Location": "Заповнити місце", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Заповнити порожні поля «Місто», «Регіон» і «Країна» за GPS (назви місць GeoNames, офлайн)", "Looking up places": "Пошук місць", - "Downloading place names": "Завантаження назв місць" + "Downloading place names": "Завантаження назв місць", + "Place names downloaded": "Назви місць завантажено", + "Place names download cancelled": "Завантаження назв місць скасовано" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 805330bf6..2ae6e716b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1891,5 +1891,7 @@ "Fill Location": "填写位置", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "根据 GPS 填写空白的城市、省/州和国家(GeoNames 地名,离线)", "Looking up places": "正在查找地点", - "Downloading place names": "正在下载地名数据" + "Downloading place names": "正在下载地名数据", + "Place names downloaded": "地名数据已下载", + "Place names download cancelled": "已取消下载地名数据" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0810fc2db..bf651fc68 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1891,5 +1891,7 @@ "Fill Location": "填寫位置", "Fill empty City, State and Country from GPS (offline GeoNames place names)": "根據 GPS 填寫空白的城市、州/省和國家(GeoNames 地名,離線)", "Looking up places": "正在查找地點", - "Downloading place names": "正在下載地名資料" + "Downloading place names": "正在下載地名資料", + "Place names downloaded": "地名資料已下載", + "Place names download cancelled": "已取消下載地名資料" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f853fe166..2b1cbadc3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -547,6 +547,18 @@ impl LightcraftApp { self.ui.toast = Some((text.into(), t + secs, None)); } + /// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do). + fn place_names_outcome(&mut self, ctx: &egui::Context) { + use lightcraft_engine::cmd::geocode::DownloadOutcome; + match self.session.place_names.take_outcome() { + Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")), + Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")), + Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e), + None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)), + None => {} + } + } + /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and /// show their errors; start a zoomed-in detail pass once the clicking stops /// (`ui.detail_due`); watch the model download. Never waits for the model. @@ -1024,6 +1036,7 @@ impl LightcraftApp { // panels set it again this frame while the pointer rests on a preset or profile self.hover_preview = None; self.ai_mask_detail(&ctx); + self.place_names_outcome(&ctx); if self.ui.fullscreen { // full-screen preview: the photo alone on black egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6b49fd891..f08da22be 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -668,14 +668,14 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast(ui.ctx(), e); } } - if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) - .on_hover_text(crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)")) - .clicked() - && let Err(e) = app.run("metadata.reverseGeocode", json!({})) - { - app.toast(ui.ctx(), e); - } }); + if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) + .on_hover_text(crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)")) + .clicked() + && let Err(e) = app.run("metadata.reverseGeocode", json!({})) + { + app.toast_error(ui.ctx(), e); + } } if let Some(a) = p.analysis { let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness);