tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit cf8cef5d52728daed362ab4ad5c9b10423721b4b
parent dd8a18a189508d30004c0a4341e520b0e3a56959
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 21:07:14 -0700

nips: add listing location parser

Diffstat:
Mcrates/tangle_nips/src/lib.rs | 220++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 218 insertions(+), 2 deletions(-)

diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs @@ -688,6 +688,100 @@ pub fn parse_listing_status(event: &Event) -> Result<Option<ListingStatus>, Stri })) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListingLocation { + location_text: Option<String>, + geohash: Option<String>, + geohash4: Option<String>, + geohash5: Option<String>, + geohash6: Option<String>, + geohash7: Option<String>, +} + +impl ListingLocation { + pub fn location_text(&self) -> Option<&str> { + self.location_text.as_deref() + } + + pub fn geohash(&self) -> Option<&str> { + self.geohash.as_deref() + } + + pub fn geohash4(&self) -> Option<&str> { + self.geohash4.as_deref() + } + + pub fn geohash5(&self) -> Option<&str> { + self.geohash5.as_deref() + } + + pub fn geohash6(&self) -> Option<&str> { + self.geohash6.as_deref() + } + + pub fn geohash7(&self) -> Option<&str> { + self.geohash7.as_deref() + } + + pub fn location_precision(&self) -> Option<usize> { + self.geohash.as_ref().map(String::len) + } +} + +pub fn parse_listing_location(event: &Event) -> Result<Option<ListingLocation>, String> { + if listing_kind_for_event(event).is_none() { + return Ok(None); + } + let location_text = optional_tag_value(event, "location")?; + if location_text.as_ref().is_some_and(String::is_empty) { + return Err("listing location tag must not be empty".to_owned()); + } + let geohash = optional_exact_tag_value(event, "g")? + .map(|value| parse_geohash(&value)) + .transpose()?; + Ok(Some(ListingLocation { + location_text, + geohash4: geohash_prefix(&geohash, 4), + geohash5: geohash_prefix(&geohash, 5), + geohash6: geohash_prefix(&geohash, 6), + geohash7: geohash_prefix(&geohash, 7), + geohash, + })) +} + +fn optional_exact_tag_value(event: &Event, name: &str) -> Result<Option<String>, String> { + let tags = matching_tags(event, name); + match tags.as_slice() { + [] => Ok(None), + [tag] => match tag.values() { + [] => Err(format!("tag `{name}` must include a value")), + [value] => Ok(Some(value.clone())), + _ => Err(format!("tag `{name}` must include exactly one value")), + }, + _ => Err(format!("tag `{name}` must not be repeated")), + } +} + +fn parse_geohash(value: &str) -> Result<String, String> { + let normalized = value.trim().to_ascii_lowercase(); + if normalized.len() < 4 + || normalized.len() > 12 + || !normalized + .bytes() + .all(|byte| b"0123456789bcdefghjkmnpqrstuvwxyz".contains(&byte)) + { + return Err("geohash must be 4 to 12 geohash characters".to_owned()); + } + Ok(normalized) +} + +fn geohash_prefix(geohash: &Option<String>, length: usize) -> Option<String> { + geohash + .as_ref() + .filter(|value| value.len() >= length) + .map(|value| value[..length].to_owned()) +} + fn listing_kind_for_event(event: &Event) -> Option<ListingKind> { match event.unsigned().kind().as_u32() { NIP99_PUBLIC_LISTING_KIND => Some(ListingKind::Public), @@ -702,8 +796,8 @@ mod tests { DeletionTarget, FulfillmentMethod, ListingEffectiveStatus, ListingKind, ListingUnit, NIP99_PUBLIC_LISTING_KIND, matching_tags, optional_tag_value, optional_tag_values, parse_deletion_request, parse_listing_fulfillment, parse_listing_identity, - parse_listing_price, parse_listing_status, parse_listing_text, parse_listing_unit, - parse_nip50_filter_search, parse_nip50_search, parse_relay_auth_event, + parse_listing_location, parse_listing_price, parse_listing_status, parse_listing_text, + parse_listing_unit, parse_nip50_filter_search, parse_nip50_search, parse_relay_auth_event, parse_required_u64_tag, parse_u64_field, repeated_or_missing_policy_boundary, required_tag_value, required_tag_values, single_letter_tag_values, single_letter_values_for, tag_count, @@ -1668,6 +1762,128 @@ mod tests { ); } + #[test] + fn listing_location_parser_extracts_text_geohash_and_prefixes() { + let event = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![ + Tag::from_parts("location", &["Olympia Farmers Market"]).expect("location"), + Tag::from_parts("g", &[" C22YZUG "]).expect("g"), + ], + ); + + let location = parse_listing_location(&event) + .expect("parse") + .expect("location"); + + assert_eq!(location.location_text(), Some("Olympia Farmers Market")); + assert_eq!(location.geohash(), Some("c22yzug")); + assert_eq!(location.geohash4(), Some("c22y")); + assert_eq!(location.geohash5(), Some("c22yz")); + assert_eq!(location.geohash6(), Some("c22yzu")); + assert_eq!(location.geohash7(), Some("c22yzug")); + assert_eq!(location.location_precision(), Some(7)); + } + + #[test] + fn listing_location_parser_allows_missing_location_fields_and_ignores_non_listings() { + let listing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); + let note = event_with_kind_and_tags( + 1, + vec![Tag::from_parts("location", &["Somewhere"]).expect("location")], + ); + + let location = parse_listing_location(&listing) + .expect("listing") + .expect("location"); + + assert_eq!(location.location_text(), None); + assert_eq!(location.geohash(), None); + assert_eq!(location.geohash4(), None); + assert_eq!(location.location_precision(), None); + assert_eq!(parse_listing_location(&note), Ok(None)); + } + + #[test] + fn listing_location_parser_rejects_malformed_location_tags() { + let repeated = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![ + Tag::from_parts("location", &["A"]).expect("location"), + Tag::from_parts("location", &["B"]).expect("location"), + ], + ); + let missing_value = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("location", &[]).expect("location")], + ); + let empty = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("location", &[""]).expect("location")], + ); + + assert_eq!( + parse_listing_location(&repeated).expect_err("repeated"), + "tag `location` must not be repeated" + ); + assert_eq!( + parse_listing_location(&missing_value).expect_err("value"), + "tag `location` must include a value" + ); + assert_eq!( + parse_listing_location(&empty).expect_err("empty"), + "listing location tag must not be empty" + ); + } + + #[test] + fn listing_location_parser_rejects_malformed_geohash_tags() { + let repeated = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![ + Tag::from_parts("g", &["c22y"]).expect("g"), + Tag::from_parts("g", &["c23n"]).expect("g"), + ], + ); + let missing_value = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("g", &[]).expect("g")], + ); + let extra_value = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("g", &["c22y", "extra"]).expect("g")], + ); + let invalid = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("g", &["c22a"]).expect("g")], + ); + let too_short = event_with_kind_and_tags( + u64::from(NIP99_PUBLIC_LISTING_KIND), + vec![Tag::from_parts("g", &["c22"]).expect("g")], + ); + + assert_eq!( + parse_listing_location(&repeated).expect_err("repeated"), + "tag `g` must not be repeated" + ); + assert_eq!( + parse_listing_location(&missing_value).expect_err("value"), + "tag `g` must include a value" + ); + assert_eq!( + parse_listing_location(&extra_value).expect_err("extra"), + "tag `g` must include exactly one value" + ); + assert_eq!( + parse_listing_location(&invalid).expect_err("invalid"), + "geohash must be 4 to 12 geohash characters" + ); + assert_eq!( + parse_listing_location(&too_short).expect_err("short"), + "geohash must be 4 to 12 geohash characters" + ); + } + fn event_with_tags(tags: Vec<Tag>) -> Event { event_with_kind_and_tags(30_402, tags) }