mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 05:28:25 -06:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0ba118de92
commit
b28b05c475
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 `<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")))
|
||||
}
|
||||
|
||||
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());
|
||||
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<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 = 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<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)
|
||||
}
|
||||
|
||||
/// `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.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<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);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<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")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "地名データをダウンロード中"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Загрузка названий мест"
|
||||
}
|
||||
|
||||
@@ -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": "Завантаження назв місць"
|
||||
}
|
||||
|
||||
@@ -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": "正在下载地名数据"
|
||||
}
|
||||
|
||||
@@ -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": "正在下載地名資料"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 (`<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) |
|
||||
| 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 | ⬜ | | |
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user