mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #948
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:
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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,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/"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
|
||||
@@ -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": "地名データのダウンロードを中止しました"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Загрузка названий мест отменена"
|
||||
}
|
||||
|
||||
@@ -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": "Завантаження назв місць скасовано"
|
||||
}
|
||||
|
||||
@@ -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": "已取消下载地名数据"
|
||||
}
|
||||
|
||||
@@ -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": "已取消下載地名資料"
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 | ⬜ | | |
|
||||
|
||||
@@ -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