neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Look up OSM ids in the search box
380834d · Gabriel Morell · 2026-10-28 20:57
Message
{commit_body(@commit)}
Files changed
modified
README.md
+6
−0
@@ -169,6 +169,12 @@ Restaurants added before hours were tracked can pick them up with
| 169 | 169 | once under **Manage** on the area page. Refresh replaces hours (they change) |
| 170 | 170 | but only fills cuisine, website and address where blank. |
| 171 | 171 | |
| 172 | +The search box also takes an **OSM id**: `29632970` (tries node, way and | |
| 173 | +relation, keeping whichever is a named place), `w29632970`, `way/29632970`, | |
| 174 | +or a pasted openstreetmap.org link. It's looked up on the OSM API, placed at | |
| 175 | +the centre of a way's or relation's outline, and shown however far away or | |
| 176 | +whatever kind of place it is, since you asked for exactly that one. | |
| 177 | + | |
| 172 | 178 | Search results from an area are sorted nearest its centre first, and |
| 173 | 179 | anything over 50 km away is dropped (Photon will otherwise offer a namesake |
| 174 | 180 | on another continent). |
modified
src/handlers/places.rs
+16
−0
@@ -47,6 +47,22 @@ pub async fn search(
| 47 | 47 | let q = qs.q.trim(); |
| 48 | 48 | let near = qs.lat.zip(qs.lon); |
| 49 | 49 | let food = qs.food.as_deref() == Some("1"); |
| 50 | + // An OSM id is an exact request: no eatery filter, no distance cut-off, | |
| 51 | + // just that element (distance still shown when there's an area). | |
| 52 | + if let Some((kind, id)) = crate::osm::parse_osm_id(q) { | |
| 53 | + let (mut places, failed) = match state.osm.lookup(kind, id).await { | |
| 54 | + Ok(p) => (p, false), | |
| 55 | + Err(e) => { | |
| 56 | + warn!(error = %e, "osm id lookup failed"); | |
| 57 | + (vec![], true) | |
| 58 | + } | |
| 59 | + }; | |
| 60 | + if let Some((lat, lon)) = near { | |
| 61 | + crate::osm::by_distance(&mut places, lat, lon, None); | |
| 62 | + } | |
| 63 | + return candidates(&state, places, failed, true, qs.area).await; | |
| 64 | + } | |
| 65 | + | |
| 50 | 66 | let (mut places, failed) = if q.is_empty() { |
| 51 | 67 | (vec![], false) |
| 52 | 68 | } else { |
modified
src/osm.rs
+106
−0
@@ -103,6 +103,31 @@ impl Place {
| 103 | 103 | } |
| 104 | 104 | } |
| 105 | 105 | |
| 106 | +/// An OSM element id typed into a search box: `29632970` (any type), | |
| 107 | +/// `w29632970`, `way/29632970`, `way 29632970`, or an openstreetmap.org URL. | |
| 108 | +/// Returns the type, if given (`node`/`way`/`relation`), and the id. | |
| 109 | +pub fn parse_osm_id(q: &str) -> Option<(Option<&'static str>, i64)> { | |
| 110 | + let q = q.trim().trim_end_matches('/'); | |
| 111 | + // URLs: take what follows the host — .../way/29632970, possibly with #map=… | |
| 112 | + let q = q.split_once("openstreetmap.org/").map_or(q, |(_, rest)| rest); | |
| 113 | + let q = q.split(['#', '?']).next().unwrap_or(q); | |
| 114 | + let kind = |k: &str| match k.to_ascii_lowercase().as_str() { | |
| 115 | + "n" | "node" => Some("node"), | |
| 116 | + "w" | "way" => Some("way"), | |
| 117 | + "r" | "rel" | "relation" => Some("relation"), | |
| 118 | + _ => None, | |
| 119 | + }; | |
| 120 | + if let Ok(id) = q.parse::<i64>() { | |
| 121 | + return (id > 0).then_some((None, id)); | |
| 122 | + } | |
| 123 | + let (k, id) = match q.split_once(['/', ' ']) { | |
| 124 | + Some((k, id)) => (k.trim(), id.trim()), | |
| 125 | + None => q.split_at(q.find(|c: char| c.is_ascii_digit())?), | |
| 126 | + }; | |
| 127 | + let id: i64 = id.parse().ok().filter(|i| *i > 0)?; | |
| 128 | + Some((Some(kind(k)?), id)) | |
| 129 | +} | |
| 130 | + | |
| 106 | 131 | /// `N123` → (`node`, 123), as the OSM API names element types. |
| 107 | 132 | fn split_ref(osm_ref: &str) -> Option<(&'static str, i64)> { |
| 108 | 133 | let kind = match osm_ref.get(..1)? { |
@@ -340,6 +365,53 @@ impl OsmClient {
| 340 | 365 | found |
| 341 | 366 | } |
| 342 | 367 | |
| 368 | + /// Look elements up by id on the OSM API: one type, or all three when | |
| 369 | + /// the id came bare (nodes, ways and relations number separately). | |
| 370 | + /// Only named elements count — a bare number usually also matches some | |
| 371 | + /// untagged node in a field somewhere. Ways and relations are placed at | |
| 372 | + /// the centre of their bounding box. | |
| 373 | + pub async fn lookup(&self, kind: Option<&'static str>, id: i64) -> Result<Vec<Place>> { | |
| 374 | + let Some(base) = self.osm_api_url.as_deref() else { return Ok(vec![]) }; | |
| 375 | + let kinds: Vec<&str> = kind.map_or(vec!["node", "way", "relation"], |k| vec![k]); | |
| 376 | + let mut places = vec![]; | |
| 377 | + for kind in kinds { | |
| 378 | + // `full` brings a way's or relation's nodes, for a position. | |
| 379 | + let url = match kind { | |
| 380 | + "node" => format!("{base}/api/0.6/node/{id}.json"), | |
| 381 | + _ => format!("{base}/api/0.6/{kind}/{id}/full.json"), | |
| 382 | + }; | |
| 383 | + let resp = self.http.get(url).timeout(Duration::from_secs(6)).send().await?; | |
| 384 | + // 404 for a type that doesn't have this id, 410 for deleted. | |
| 385 | + if matches!(resp.status().as_u16(), 404 | 410) { | |
| 386 | + continue; | |
| 387 | + } | |
| 388 | + let body: OsmFull = resp.error_for_status()?.json().await.context("decoding osm element")?; | |
| 389 | + let Some(element) = body.elements.iter().find(|e| e.kind == kind && e.id == id) else { continue }; | |
| 390 | + let Some(name) = element.tags.get("name").filter(|n| !n.is_empty()).cloned() else { continue }; | |
| 391 | + let (lats, lons): (Vec<f64>, Vec<f64>) = | |
| 392 | + body.elements.iter().filter_map(|e| Some((e.lat?, e.lon?))).unzip(); | |
| 393 | + if lats.is_empty() { | |
| 394 | + continue; | |
| 395 | + } | |
| 396 | + let mid = |v: &[f64]| (v.iter().copied().fold(f64::INFINITY, f64::min) + v.iter().copied().fold(f64::NEG_INFINITY, f64::max)) / 2.0; | |
| 397 | + let d = details(&element.tags); | |
| 398 | + places.push(Place { | |
| 399 | + osm_ref: Some(format!("{}{id}", kind[..1].to_ascii_uppercase())), | |
| 400 | + name, | |
| 401 | + lat: mid(&lats), | |
| 402 | + lon: mid(&lons), | |
| 403 | + kind: d.kind.or_else(|| element.tags.get("shop").cloned()), | |
| 404 | + cuisine: d.cuisine, | |
| 405 | + address: d.address, | |
| 406 | + website: d.website, | |
| 407 | + opening_hours: d.opening_hours, | |
| 408 | + distance_m: None, | |
| 409 | + tagged: true, | |
| 410 | + }); | |
| 411 | + } | |
| 412 | + Ok(places) | |
| 413 | + } | |
| 414 | + | |
| 343 | 415 | async fn poi_nearby(&self, lat: f64, lon: f64, radius_m: u32, cuisine: Option<&str>) -> Result<Vec<Place>> { |
| 344 | 416 | let base = self.poi_url.as_deref().unwrap_or_default(); |
| 345 | 417 | let mut params: Vec<(&str, String)> = vec![ |
@@ -466,6 +538,23 @@ struct OsmElement {
| 466 | 538 | tags: Tags, |
| 467 | 539 | } |
| 468 | 540 | |
| 541 | +/// A `full` response: the element plus everything it's made of. | |
| 542 | +#[derive(Deserialize)] | |
| 543 | +struct OsmFull { | |
| 544 | + elements: Vec<OsmFullElement>, | |
| 545 | +} | |
| 546 | + | |
| 547 | +#[derive(Deserialize)] | |
| 548 | +struct OsmFullElement { | |
| 549 | + #[serde(rename = "type")] | |
| 550 | + kind: String, | |
| 551 | + id: i64, | |
| 552 | + lat: Option<f64>, | |
| 553 | + lon: Option<f64>, | |
| 554 | + #[serde(default)] | |
| 555 | + tags: Tags, | |
| 556 | +} | |
| 557 | + | |
| 469 | 558 | #[derive(Deserialize)] |
| 470 | 559 | struct PhotonCollection { |
| 471 | 560 | features: Vec<PhotonFeature>, |
@@ -632,6 +721,23 @@ mod tests {
| 632 | 721 | assert!(places[0].distance_m.unwrap() < 100); |
| 633 | 722 | } |
| 634 | 723 | |
| 724 | + #[test] | |
| 725 | + fn parses_osm_ids_however_theyre_typed() { | |
| 726 | + assert_eq!(parse_osm_id("29632970"), Some((None, 29632970))); | |
| 727 | + assert_eq!(parse_osm_id(" w29632970 "), Some((Some("way"), 29632970))); | |
| 728 | + assert_eq!(parse_osm_id("N123"), Some((Some("node"), 123))); | |
| 729 | + assert_eq!(parse_osm_id("way/29632970"), Some((Some("way"), 29632970))); | |
| 730 | + assert_eq!(parse_osm_id("relation 42"), Some((Some("relation"), 42))); | |
| 731 | + assert_eq!( | |
| 732 | + parse_osm_id("https://www.openstreetmap.org/way/29632970#map=19/42.36/-71.06"), | |
| 733 | + Some((Some("way"), 29632970)) | |
| 734 | + ); | |
| 735 | + assert_eq!(parse_osm_id("https://openstreetmap.org/node/6974241986/"), Some((Some("node"), 6974241986))); | |
| 736 | + for not_an_id in ["pizza", "7-eleven", "route 66", "w", "x123", "0", "way/abc", "99 bottles"] { | |
| 737 | + assert_eq!(parse_osm_id(not_an_id), None, "{not_an_id}"); | |
| 738 | + } | |
| 739 | + } | |
| 740 | + | |
| 635 | 741 | #[test] |
| 636 | 742 | fn haversine_is_sane() { |
| 637 | 743 | // ~111 km per degree of latitude. |
modified
templates/areas/new.html
+1
−1
@@ -9,7 +9,7 @@
| 9 | 9 | <form class="join w-full" |
| 10 | 10 | hx-get="/places/search" hx-target="#candidates" hx-select="#candidates" hx-swap="outerHTML"> |
| 11 | 11 | <input type="hidden" name="food" value="0"/> |
| 12 | − <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Austin, TX · 1 Main St, Somerville" required/> | |
| 12 | + <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Austin, TX · 1 Main St, Somerville · an OSM id" required/> | |
| 13 | 13 | <button class="btn btn-sm join-item" type="submit"><i class="fas fa-magnifying-glass"></i> Search</button> |
| 14 | 14 | </form> |
| 15 | 15 | <div id="candidates"></div> |
modified
templates/restaurants/new.html
+2
−2
@@ -5,7 +5,7 @@
| 5 | 5 | <a href="/areas/{{ area.id }}" class="btn btn-ghost btn-sm">← {{ area.name }}</a> |
| 6 | 6 | <h2 class="text-2xl font-bold text-accent">Add a restaurant</h2> |
| 7 | 7 | <p class="text-sm opacity-70"> |
| 8 | − Search by name, or click a spot on the map to see what's there. Pick one to fill in the | |
| 8 | + Search by name or OSM id (or paste an openstreetmap.org link), or click a spot on the map to see what's there. Pick one to fill in the | |
| 9 | 9 | details, or drop a pin and type them yourself for somewhere OSM doesn't know about. |
| 10 | 10 | </p> |
| 11 | 11 |
@@ -15,7 +15,7 @@
| 15 | 15 | <input type="hidden" name="area" value="{{ area.id }}"/> |
| 16 | 16 | <input type="hidden" name="lat" value="{{ area.lat }}"/> |
| 17 | 17 | <input type="hidden" name="lon" value="{{ area.lon }}"/> |
| 18 | − <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Tandoor, burrito, Dunkin'…" required/> | |
| 18 | + <input class="input input-bordered input-sm join-item flex-1" type="search" name="q" placeholder="Tandoor, burrito, or an OSM id like w29632970" required/> | |
| 19 | 19 | <button class="btn btn-sm join-item" type="submit"> |
| 20 | 20 | <i class="fas fa-magnifying-glass"></i> Search |
| 21 | 21 | <span class="loading loading-spinner loading-xs htmx-indicator"></span> |
Parents: 6c4a5fa