On merge: metadata.downloadPlaceNames accepts plain http only for the loopback address itself
(http://127.0.0.1.example.com/ and http://127.0.0.1@host/ are refused; test
download_base_is_https_or_loopback_only).
This commit is contained in:
Claude
2026-10-11 11:52:57 +00:00
24 changed files with 1034 additions and 11 deletions
Generated
+9
View File
@@ -3211,6 +3211,7 @@ dependencies = [
"lightcraft-develop",
"lightcraft-faces",
"lightcraft-fetch",
"lightcraft-geocode",
"lightcraft-geom",
"lightcraft-gpu",
"lightcraft-heif",
@@ -3258,6 +3259,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"
+1
View File
@@ -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"
+7
View File
@@ -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-<family>.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.
+1
View File
@@ -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 }
+472
View File
@@ -0,0 +1,472 @@
//! 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::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::SystemTime;
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 `<settings>/geonames`.
fn dataset_dir(p: &Value) -> Option<PathBuf> {
str_param(p, "dataset").map(PathBuf::from).or_else(|| crate::config::config_dir().map(|d| d.join("geonames")))
}
/// 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<DownloadOutcome>,
/// The last outcome, until the UI has shown it ([`PlaceNames::take_outcome`]).
unreported: Option<DownloadOutcome>,
}
/// 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<Mutex<DownloadShared>>,
/// (cities file, its size, its modification time, the parsed names).
cache: Option<(PathBuf, u64, Option<SystemTime>, Arc<GeoNames>)>,
}
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<DownloadOutcome> {
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<Arc<GeoNames>, 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<Value> {
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());
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`.
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<Value> {
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 = 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();
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<usize, String> {
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)
}
/// A download base: https, or plain http to this machine's loopback address only (a test
/// server), with nothing but a port or path after the host, so `http://127.0.0.1.example.com/`
/// is refused.
pub(super) fn base_allowed(base: &str) -> bool {
base.starts_with("https://")
|| base.strip_prefix("http://127.0.0.1").is_some_and(|rest| rest.is_empty() || rest.starts_with(':') || rest.starts_with('/'))
}
/// `metadata.downloadPlaceNames`: starts a background download (shown in the activity stack, cancellable).
pub(super) fn start(s: &mut Session, p: &Value) -> Result<Value> {
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_allowed(&base) {
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();
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}))
}
}
pub fn specs() -> Vec<CommandSpec> {
#[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);
}
#[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<u8>, Option<usize>) = 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"<html>not a zip</html>".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_end_to_end_and_its_failures() {
let mut s = Session::with_demo();
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);
}
#[test]
fn download_base_is_https_or_loopback_only() {
assert!(download::base_allowed("https://download.geonames.org/export/dump/"));
assert!(download::base_allowed("http://127.0.0.1:8080"));
assert!(download::base_allowed("http://127.0.0.1/dump"));
assert!(!download::base_allowed("http://127.0.0.1.example.com/"));
assert!(!download::base_allowed("http://127.0.0.1@example.com/"));
assert!(!download::base_allowed("http://download.geonames.org/"));
}
}
+2
View File
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
mod face_models;
mod face_recognize;
pub mod filters;
pub mod geocode;
pub mod keywords;
pub mod library;
pub mod lut_profiles;
@@ -158,6 +159,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());
+3
View File
@@ -190,6 +190,8 @@ pub struct Session {
pub face_models_dir: Option<std::path::PathBuf>,
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.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.
@@ -316,6 +318,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(),
+13
View File
@@ -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 }
+17
View File
@@ -0,0 +1,17 @@
# lightcraft-geocode (L1)
Offline reverse geocoding: a GPS position becomes Sublocation / City / State / Country.
- **Data:** the GeoNames dump (<https://download.geonames.org/export/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.
+273
View File
@@ -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<City>,
cells: HashMap<(i32, i32), Vec<u32>>,
admin1: HashMap<String, String>,
countries: HashMap<String, String>,
}
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::<f64>(), lon.trim().parse::<f64>()) 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<GeoNames, GeocodeError> {
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<Place> {
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);
}
}
+123
View File
@@ -0,0 +1,123 @@
//! 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<u16> {
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<u32> {
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<Vec<u8>, 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")))
}
/// A one-file deflated ZIP (test fixtures: the engine's download test serves one).
#[doc(hidden)]
pub fn make(name: &str, data: &[u8]) -> Vec<u8> {
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);
}
}
}
+10 -1
View File
@@ -2048,5 +2048,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek. Stattdessen in eine LightCraft-Bibliothek importieren?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Der Import liest den Katalog, ohne ihn zu ändern, und fügt seine Fotos, Bearbeitungen, Stichwörter und Sammlungen der jetzt geöffneten Bibliothek hinzu.",
"Albums hold library photos: Add to My Photos first": "Alben enthalten Bibliotheksfotos: zuerst zu „Meine Fotos“ hinzufügen",
"Add to My Photos First": "Zuerst zu „Meine Fotos“ hinzufügen"
"Add to My Photos First": "Zuerst zu „Meine Fotos“ hinzufügen",
"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",
"Place names downloaded": "Ortsnamen heruntergeladen",
"Place names download cancelled": "Download der Ortsnamen abgebrochen"
}
+10 -1
View File
@@ -1893,5 +1893,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Esto es un catálogo de Lightroom, no una biblioteca de LightCraft. ¿Importarlo a una biblioteca de LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora.",
"Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añádelas primero a Mis fotos",
"Add to My Photos First": "Añadir primero a Mis fotos"
"Add to My Photos First": "Añadir primero a Mis fotos",
"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",
"Place names downloaded": "Nombres de lugares descargados",
"Place names download cancelled": "Descarga de nombres de lugares cancelada"
}
+10 -1
View File
@@ -1893,5 +1893,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ceci est un catalogue Lightroom, pas une bibliothèque LightCraft. L’importer plutôt dans une bibliothèque LightCraft ?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte.",
"Albums hold library photos: Add to My Photos first": "Les albums contiennent des photos de la bibliothèque : ajoutez-les d’abord à Mes photos",
"Add to My Photos First": "Ajouter d’abord à Mes photos"
"Add to My Photos First": "Ajouter d’abord à Mes photos",
"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",
"Place names downloaded": "Noms de lieux téléchargés",
"Place names download cancelled": "Téléchargement des noms de lieux annulé"
}
+10 -1
View File
@@ -1895,5 +1895,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "これは Lightroom カタログで、LightCraft ライブラリではありません。代わりに LightCraft ライブラリに読み込みますか?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "読み込みはカタログを変更せずに読み取り、写真・編集・キーワード・コレクションを現在開いているライブラリに追加します。",
"Albums hold library photos: Add to My Photos first": "アルバムに入れられるのはライブラリの写真です:先にマイフォトに追加してください",
"Add to My Photos First": "先にマイフォトに追加"
"Add to My Photos First": "先にマイフォトに追加",
"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": "地名データをダウンロード中",
"Place names downloaded": "地名データをダウンロードしました",
"Place names download cancelled": "地名データのダウンロードを中止しました"
}
+10 -1
View File
@@ -1893,5 +1893,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Este é um catálogo do Lightroom, não uma biblioteca do LightCraft. Importá-lo para uma biblioteca do LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "A importação lê o catálogo sem alterá-lo e adiciona as fotos, edições, palavras-chave e coleções dele à biblioteca aberta agora.",
"Albums hold library photos: Add to My Photos first": "Álbuns contêm fotos da biblioteca: adicione-as às Minhas fotos primeiro",
"Add to My Photos First": "Adicionar às Minhas fotos primeiro"
"Add to My Photos First": "Adicionar às Minhas fotos primeiro",
"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",
"Place names downloaded": "Nomes de lugares baixados",
"Place names download cancelled": "Download dos nomes de lugares cancelado"
}
+10 -1
View File
@@ -1607,5 +1607,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Это каталог Lightroom, а не библиотека LightCraft. Импортировать его в библиотеку LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Импорт читает каталог, не изменяя его, и добавляет его фото, правки, ключевые слова и коллекции в открытую сейчас библиотеку.",
"Albums hold library photos: Add to My Photos first": "В альбомах только фото из библиотеки: сначала добавьте их в «Мои фото»",
"Add to My Photos First": "Сначала добавить в «Мои фото»"
"Add to My Photos First": "Сначала добавить в «Мои фото»",
"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": "Загрузка названий мест",
"Place names downloaded": "Названия мест загружены",
"Place names download cancelled": "Загрузка названий мест отменена"
}
+10 -1
View File
@@ -2087,5 +2087,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Це каталог Lightroom, а не бібліотека LightCraft. Імпортувати його до бібліотеки LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки.",
"Albums hold library photos: Add to My Photos first": "В альбомах лише фото з бібліотеки: спершу додайте їх до моїх фото",
"Add to My Photos First": "Спершу додати до моїх фото"
"Add to My Photos First": "Спершу додати до моїх фото",
"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": "Завантаження назв місць",
"Place names downloaded": "Назви місць завантажено",
"Place names download cancelled": "Завантаження назв місць скасовано"
}
+10 -1
View File
@@ -1893,5 +1893,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "这是 Lightroom 目录,不是 LightCraft 图库。要改为将其导入 LightCraft 图库吗?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "导入只读取目录、不做任何更改,并将其中的照片、编辑、关键字和收藏夹添加到当前打开的图库。",
"Albums hold library photos: Add to My Photos first": "相册只能收录图库中的照片:请先添加到我的照片",
"Add to My Photos First": "先添加到我的照片"
"Add to My Photos First": "先添加到我的照片",
"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": "正在下载地名数据",
"Place names downloaded": "地名数据已下载",
"Place names download cancelled": "已取消下载地名数据"
}
+10 -1
View File
@@ -1893,5 +1893,14 @@
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。",
"Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片",
"Add to My Photos First": "先加入我的照片"
"Add to My Photos First": "先加入我的照片",
"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": "正在下載地名資料",
"Place names downloaded": "地名資料已下載",
"Place names download cancelled": "已取消下載地名資料"
}
+13
View File
@@ -600,6 +600,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.
@@ -1102,6 +1114,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));
+7
View File
@@ -669,6 +669,13 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
});
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);
+2 -2
View File
@@ -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 (`<file>.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); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal) |
| 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 |
@@ -720,7 +720,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 | ⬜ | | |
+1
View File
@@ -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)),