From a8d36a234aa89a2990f2320695bdaf3bead70611 Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:38:11 +0000 Subject: [PATCH 1/8] nip52: add NIP-52 Calendar Events support Add support for NIP-52 (Calendar Events) with four event kinds: - DateBasedCalendarEvent (31922) - TimeBasedCalendarEvent (31923) - Calendar (31924) - CalendarEventRsvp (31925) Includes new TagKind variants (Start, End, Location), a Location TagStandard variant, and full tag round-trip conversion for all four structs with validation and tests. Closes #829 --- crates/nostr/src/event/kind.rs | 4 + crates/nostr/src/event/tag/kind.rs | 18 + crates/nostr/src/event/tag/standard.rs | 7 + crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip52.rs | 1110 ++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 6 files changed, 1141 insertions(+) create mode 100644 crates/nostr/src/nips/nip52.rs diff --git a/crates/nostr/src/event/kind.rs b/crates/nostr/src/event/kind.rs index dab187c77..1f97860f4 100644 --- a/crates/nostr/src/event/kind.rs +++ b/crates/nostr/src/event/kind.rs @@ -175,6 +175,10 @@ kind_variants! { ChatMessage => 9, "Chat Message", "", Thread => 11, "Thread", "", WebBookmark => 39701, "Web Bookmark", "", + DateBasedCalendarEvent => 31922, "Date-based Calendar Event", "", + TimeBasedCalendarEvent => 31923, "Time-based Calendar Event", "", + Calendar => 31924, "Calendar", "", + CalendarEventRsvp => 31925, "Calendar Event RSVP", "", } impl PartialEq for Kind { diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index d0a77b5c1..6fb97c8f6 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -69,6 +69,10 @@ pub enum TagKind<'a> { Emoji, /// Encrypted Encrypted, + /// End (singular, NIP-52) + /// + /// + End, /// Ends Ends, /// Expiration @@ -83,6 +87,10 @@ pub enum TagKind<'a> { File, /// Image Image, + /// Location + /// + /// + Location, /// License of the shared content /// /// @@ -147,6 +155,10 @@ pub enum TagKind<'a> { Server, /// Size of the file in bytes Size, + /// Start (singular, NIP-52) + /// + /// + Start, /// Starts Starts, /// Status @@ -336,12 +348,14 @@ impl<'a> TagKind<'a> { Self::Dim => "dim", Self::Emoji => "emoji", Self::Encrypted => "encrypted", + Self::End => "end", Self::Ends => "ends", Self::Expiration => "expiration", Self::Extension => "extension", Self::File => "file", Self::Head => "HEAD", Self::Image => "image", + Self::Location => "location", Self::License => "license", Self::Lnurl => "lnurl", Self::Magnet => "magnet", @@ -369,6 +383,7 @@ impl<'a> TagKind<'a> { Self::Runtime => "runtime", Self::Server => "server", Self::Size => "size", + Self::Start => "start", Self::Starts => "starts", Self::Status => "status", Self::Streaming => "streaming", @@ -415,11 +430,13 @@ impl<'a> From<&'a str> for TagKind<'a> { "dim" => Self::Dim, "emoji" => Self::Emoji, "encrypted" => Self::Encrypted, + "end" => Self::End, "ends" => Self::Ends, "expiration" => Self::Expiration, "extension" => Self::Extension, "file" => Self::File, "image" => Self::Image, + "location" => Self::Location, "license" => Self::License, "lnurl" => Self::Lnurl, "magnet" => Self::Magnet, @@ -447,6 +464,7 @@ impl<'a> From<&'a str> for TagKind<'a> { "HEAD" => Self::Head, "server" => Self::Server, "size" => Self::Size, + "start" => Self::Start, "starts" => Self::Starts, "status" => Self::Status, "streaming" => Self::Streaming, diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 0d2d4c5f3..3d0f3962a 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -132,6 +132,10 @@ pub enum TagStandard { Hashtag(String), Geohash(String), Identifier(String), + /// Location + /// + /// + Location(String), /// External Content ID /// /// @@ -517,6 +521,7 @@ impl TagStandard { TagKind::Word => Ok(Self::Word(tag_1.to_string())), TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), TagKind::Dim => Ok(Self::Dim(ImageDimensions::from_str(tag_1)?)), + TagKind::Location => Ok(Self::Location(tag_1.to_string())), _ => Err(Error::UnknownStandardizedTag), }; } @@ -658,6 +663,7 @@ impl TagStandard { character: Alphabet::D, uppercase: false, }), + Self::Location(..) => TagKind::Location, Self::ExternalContent { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::I, uppercase: *uppercase, @@ -901,6 +907,7 @@ impl From for Vec { TagStandard::Hashtag(t) => vec![tag_kind, t], TagStandard::Geohash(g) => vec![tag_kind, g], TagStandard::Identifier(d) => vec![tag_kind, d], + TagStandard::Location(loc) => vec![tag_kind, loc], TagStandard::Coordinate { coordinate, relay_url, diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index c9a00942c..6963feda3 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -38,6 +38,7 @@ pub mod nip48; #[cfg(feature = "nip49")] pub mod nip49; pub mod nip51; +pub mod nip52; pub mod nip53; pub mod nip56; #[cfg(feature = "nip57")] diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs new file mode 100644 index 000000000..0fb00d34f --- /dev/null +++ b/crates/nostr/src/nips/nip52.rs @@ -0,0 +1,1110 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-52: Calendar Events +//! +//! + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; +use core::str::FromStr; + +use crate::nips::nip01::Coordinate; +use crate::types::{RelayUrl, Url}; +use crate::{EventId, ImageDimensions, PublicKey, Tag, TagKind, TagStandard, Timestamp}; + +const START_TZID_STR: &str = "start_tzid"; +const END_TZID_STR: &str = "end_tzid"; +const FB_STR: &str = "fb"; + +/// NIP-52 Error +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// Identifier (`d` tag) missing + IdentifierMissing, + /// Title missing + TitleMissing, + /// Start missing + StartMissing, + /// Status missing + StatusMissing, + /// Coordinate (`a` tag) missing + CoordinateMissing, + /// Unknown RSVP status + UnknownRsvpStatus(String), + /// Unknown free/busy value + UnknownFreeBusy(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IdentifierMissing => f.write_str("Missing identifier (d tag)"), + Self::TitleMissing => f.write_str("Missing title"), + Self::StartMissing => f.write_str("Missing start"), + Self::StatusMissing => f.write_str("Missing status"), + Self::CoordinateMissing => f.write_str("Missing coordinate (a tag)"), + Self::UnknownRsvpStatus(s) => write!(f, "Unknown RSVP status: {s}"), + Self::UnknownFreeBusy(s) => write!(f, "Unknown free/busy value: {s}"), + } + } +} + +/// Calendar Event RSVP Status +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CalendarEventRsvpStatus { + /// Accepted + Accepted, + /// Declined + Declined, + /// Tentative + Tentative, +} + +impl fmt::Display for CalendarEventRsvpStatus { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl CalendarEventRsvpStatus { + /// Get as `&str` + pub fn as_str(&self) -> &str { + match self { + Self::Accepted => "accepted", + Self::Declined => "declined", + Self::Tentative => "tentative", + } + } +} + +impl FromStr for CalendarEventRsvpStatus { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "accepted" => Ok(Self::Accepted), + "declined" => Ok(Self::Declined), + "tentative" => Ok(Self::Tentative), + s => Err(Error::UnknownRsvpStatus(s.to_string())), + } + } +} + +/// Free/Busy indicator +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FreeBusy { + /// Free + Free, + /// Busy + Busy, +} + +impl fmt::Display for FreeBusy { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FreeBusy { + /// Get as `&str` + pub fn as_str(&self) -> &str { + match self { + Self::Free => "free", + Self::Busy => "busy", + } + } +} + +impl FromStr for FreeBusy { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "free" => Ok(Self::Free), + "busy" => Ok(Self::Busy), + s => Err(Error::UnknownFreeBusy(s.to_string())), + } + } +} + +/// Date-Based Calendar Event (kind 31922) +/// +/// +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DateBasedCalendarEvent { + /// Identifier (`d` tag) + pub id: String, + /// Title + pub title: String, + /// Start date (YYYY-MM-DD) + pub start: String, + /// End date (YYYY-MM-DD, exclusive) + pub end: Option, + /// Summary + pub summary: Option, + /// Image + pub image: Option<(Url, Option)>, + /// Location + pub location: Option, + /// Geohash + pub geohash: Option, + /// Participants (pubkey, optional relay URL, optional role) + pub participants: Vec<(PublicKey, Option, Option)>, + /// Hashtags + pub hashtags: Vec, + /// References + pub references: Vec, + /// Coordinates (`a` tags) + pub coordinates: Vec<(Coordinate, Option)>, +} + +impl DateBasedCalendarEvent { + /// Create a new date-based calendar event + pub fn new(id: S1, title: S2, start: S3) -> Self + where + S1: Into, + S2: Into, + S3: Into, + { + Self { + id: id.into(), + title: title.into(), + start: start.into(), + end: None, + summary: None, + image: None, + location: None, + geohash: None, + participants: Vec::new(), + hashtags: Vec::new(), + references: Vec::new(), + coordinates: Vec::new(), + } + } +} + +impl From for Vec { + fn from(event: DateBasedCalendarEvent) -> Self { + let DateBasedCalendarEvent { + id, + title, + start, + end, + summary, + image, + location, + geohash, + participants, + hashtags, + references, + coordinates, + } = event; + + let mut tags = Vec::new(); + + tags.push(Tag::identifier(id)); + tags.push(Tag::from_standardized_without_cell(TagStandard::Title( + title, + ))); + tags.push(Tag::custom(TagKind::Start, [&start])); + + if let Some(end) = end { + tags.push(Tag::custom(TagKind::End, [&end])); + } + + if let Some(summary) = summary { + tags.push(Tag::from_standardized_without_cell(TagStandard::Summary( + summary, + ))); + } + + if let Some((image, dim)) = image { + tags.push(Tag::from_standardized_without_cell(TagStandard::Image( + image, dim, + ))); + } + + if let Some(location) = location { + tags.push(Tag::from_standardized_without_cell(TagStandard::Location( + location, + ))); + } + + if let Some(geohash) = geohash { + tags.push(Tag::from_standardized_without_cell(TagStandard::Geohash( + geohash, + ))); + } + + for (pubkey, relay_url, role) in participants { + tags.push(Tag::from_standardized_without_cell(TagStandard::PublicKey { + public_key: pubkey, + relay_url, + alias: role, + uppercase: false, + })); + } + + for hashtag in hashtags { + tags.push(Tag::from_standardized_without_cell(TagStandard::Hashtag( + hashtag, + ))); + } + + for reference in references { + tags.push(Tag::from_standardized_without_cell( + TagStandard::Reference(reference), + )); + } + + for (coordinate, relay_url) in coordinates { + tags.push(Tag::from_standardized_without_cell( + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + }, + )); + } + + tags + } +} + +impl TryFrom> for DateBasedCalendarEvent { + type Error = Error; + + fn try_from(tags: Vec) -> Result { + let id: &str = tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or(Error::IdentifierMissing)?; + + let mut event = DateBasedCalendarEvent { + id: id.to_string(), + title: String::new(), + start: String::new(), + end: None, + summary: None, + image: None, + location: None, + geohash: None, + participants: Vec::new(), + hashtags: Vec::new(), + references: Vec::new(), + coordinates: Vec::new(), + }; + + let mut has_title = false; + let mut has_start = false; + + for tag in tags.into_iter() { + match tag.kind() { + TagKind::Start => { + if let Some(content) = tag.content() { + event.start = content.to_string(); + has_start = true; + } + } + TagKind::End => { + if let Some(content) = tag.content() { + event.end = Some(content.to_string()); + } + } + _ => { + if let Some(std_tag) = tag.to_standardized() { + match std_tag { + TagStandard::Title(title) => { + event.title = title; + has_title = true; + } + TagStandard::Summary(summary) => event.summary = Some(summary), + TagStandard::Image(url, dim) => event.image = Some((url, dim)), + TagStandard::Location(loc) => event.location = Some(loc), + TagStandard::Geohash(g) => event.geohash = Some(g), + TagStandard::PublicKey { + public_key, + relay_url, + alias, + uppercase: false, + } => { + event.participants.push((public_key, relay_url, alias)); + } + TagStandard::Hashtag(h) => event.hashtags.push(h), + TagStandard::Reference(r) => event.references.push(r), + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + } => { + event.coordinates.push((coordinate, relay_url)); + } + _ => {} + } + } + } + } + } + + if !has_title { + return Err(Error::TitleMissing); + } + if !has_start { + return Err(Error::StartMissing); + } + + Ok(event) + } +} + +/// Time-Based Calendar Event (kind 31923) +/// +/// +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TimeBasedCalendarEvent { + /// Identifier (`d` tag) + pub id: String, + /// Title + pub title: String, + /// Start timestamp (Unix) + pub start: Timestamp, + /// End timestamp (Unix) + pub end: Option, + /// Start timezone (IANA) + pub start_tzid: Option, + /// End timezone (IANA) + pub end_tzid: Option, + /// Summary + pub summary: Option, + /// Image + pub image: Option<(Url, Option)>, + /// Location + pub location: Option, + /// Geohash + pub geohash: Option, + /// Participants (pubkey, optional relay URL, optional role) + pub participants: Vec<(PublicKey, Option, Option)>, + /// Hashtags + pub hashtags: Vec, + /// References + pub references: Vec, + /// Coordinates (`a` tags) + pub coordinates: Vec<(Coordinate, Option)>, +} + +impl TimeBasedCalendarEvent { + /// Create a new time-based calendar event + pub fn new(id: S1, title: S2, start: Timestamp) -> Self + where + S1: Into, + S2: Into, + { + Self { + id: id.into(), + title: title.into(), + start, + end: None, + start_tzid: None, + end_tzid: None, + summary: None, + image: None, + location: None, + geohash: None, + participants: Vec::new(), + hashtags: Vec::new(), + references: Vec::new(), + coordinates: Vec::new(), + } + } +} + +impl From for Vec { + fn from(event: TimeBasedCalendarEvent) -> Self { + let TimeBasedCalendarEvent { + id, + title, + start, + end, + start_tzid, + end_tzid, + summary, + image, + location, + geohash, + participants, + hashtags, + references, + coordinates, + } = event; + + let mut tags = Vec::new(); + + tags.push(Tag::identifier(id)); + tags.push(Tag::from_standardized_without_cell(TagStandard::Title( + title, + ))); + tags.push(Tag::custom(TagKind::Start, [start.to_string()])); + + if let Some(end) = end { + tags.push(Tag::custom(TagKind::End, [end.to_string()])); + } + + if let Some(tzid) = start_tzid { + tags.push(Tag::custom(TagKind::custom(START_TZID_STR), [tzid])); + } + + if let Some(tzid) = end_tzid { + tags.push(Tag::custom(TagKind::custom(END_TZID_STR), [tzid])); + } + + if let Some(summary) = summary { + tags.push(Tag::from_standardized_without_cell(TagStandard::Summary( + summary, + ))); + } + + if let Some((image, dim)) = image { + tags.push(Tag::from_standardized_without_cell(TagStandard::Image( + image, dim, + ))); + } + + if let Some(location) = location { + tags.push(Tag::from_standardized_without_cell(TagStandard::Location( + location, + ))); + } + + if let Some(geohash) = geohash { + tags.push(Tag::from_standardized_without_cell(TagStandard::Geohash( + geohash, + ))); + } + + for (pubkey, relay_url, role) in participants { + tags.push(Tag::from_standardized_without_cell(TagStandard::PublicKey { + public_key: pubkey, + relay_url, + alias: role, + uppercase: false, + })); + } + + for hashtag in hashtags { + tags.push(Tag::from_standardized_without_cell(TagStandard::Hashtag( + hashtag, + ))); + } + + for reference in references { + tags.push(Tag::from_standardized_without_cell( + TagStandard::Reference(reference), + )); + } + + for (coordinate, relay_url) in coordinates { + tags.push(Tag::from_standardized_without_cell( + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + }, + )); + } + + tags + } +} + +impl TryFrom> for TimeBasedCalendarEvent { + type Error = Error; + + fn try_from(tags: Vec) -> Result { + let id: &str = tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or(Error::IdentifierMissing)?; + + let mut event = TimeBasedCalendarEvent { + id: id.to_string(), + title: String::new(), + start: Timestamp::from(0), + end: None, + start_tzid: None, + end_tzid: None, + summary: None, + image: None, + location: None, + geohash: None, + participants: Vec::new(), + hashtags: Vec::new(), + references: Vec::new(), + coordinates: Vec::new(), + }; + + let mut has_title = false; + let mut has_start = false; + + for tag in tags.into_iter() { + match tag.kind() { + TagKind::Start => { + if let Some(content) = tag.content() { + if let Ok(ts) = Timestamp::from_str(content) { + event.start = ts; + has_start = true; + } + } + } + TagKind::End => { + if let Some(content) = tag.content() { + if let Ok(ts) = Timestamp::from_str(content) { + event.end = Some(ts); + } + } + } + TagKind::Custom(ref s) if s.as_ref() == START_TZID_STR => { + if let Some(content) = tag.content() { + event.start_tzid = Some(content.to_string()); + } + } + TagKind::Custom(ref s) if s.as_ref() == END_TZID_STR => { + if let Some(content) = tag.content() { + event.end_tzid = Some(content.to_string()); + } + } + _ => { + if let Some(std_tag) = tag.to_standardized() { + match std_tag { + TagStandard::Title(title) => { + event.title = title; + has_title = true; + } + TagStandard::Summary(summary) => event.summary = Some(summary), + TagStandard::Image(url, dim) => event.image = Some((url, dim)), + TagStandard::Location(loc) => event.location = Some(loc), + TagStandard::Geohash(g) => event.geohash = Some(g), + TagStandard::PublicKey { + public_key, + relay_url, + alias, + uppercase: false, + } => { + event.participants.push((public_key, relay_url, alias)); + } + TagStandard::Hashtag(h) => event.hashtags.push(h), + TagStandard::Reference(r) => event.references.push(r), + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + } => { + event.coordinates.push((coordinate, relay_url)); + } + _ => {} + } + } + } + } + } + + if !has_title { + return Err(Error::TitleMissing); + } + if !has_start { + return Err(Error::StartMissing); + } + + Ok(event) + } +} + +/// Calendar (kind 31924) +/// +/// +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Calendar { + /// Identifier (`d` tag) + pub id: String, + /// Title + pub title: String, + /// Calendar event coordinates (`a` tags) + pub coordinates: Vec<(Coordinate, Option)>, +} + +impl Calendar { + /// Create a new calendar + pub fn new(id: S1, title: S2) -> Self + where + S1: Into, + S2: Into, + { + Self { + id: id.into(), + title: title.into(), + coordinates: Vec::new(), + } + } +} + +impl From for Vec { + fn from(calendar: Calendar) -> Self { + let Calendar { + id, + title, + coordinates, + } = calendar; + + let mut tags = Vec::new(); + + tags.push(Tag::identifier(id)); + tags.push(Tag::from_standardized_without_cell(TagStandard::Title( + title, + ))); + + for (coordinate, relay_url) in coordinates { + tags.push(Tag::from_standardized_without_cell( + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + }, + )); + } + + tags + } +} + +impl TryFrom> for Calendar { + type Error = Error; + + fn try_from(tags: Vec) -> Result { + let id: &str = tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or(Error::IdentifierMissing)?; + + let mut calendar = Calendar { + id: id.to_string(), + title: String::new(), + coordinates: Vec::new(), + }; + + let mut has_title = false; + + for tag in tags.into_iter() { + if let Some(std_tag) = tag.to_standardized() { + match std_tag { + TagStandard::Title(title) => { + calendar.title = title; + has_title = true; + } + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + } => { + calendar.coordinates.push((coordinate, relay_url)); + } + _ => {} + } + } + } + + if !has_title { + return Err(Error::TitleMissing); + } + + Ok(calendar) + } +} + +/// Calendar Event RSVP (kind 31925) +/// +/// +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CalendarEventRsvp { + /// Identifier (`d` tag) + pub id: String, + /// Coordinate of the calendar event (`a` tag, required) + pub coordinate: (Coordinate, Option), + /// RSVP status + pub status: CalendarEventRsvpStatus, + /// Optional event ID (`e` tag, specific revision) + pub event_id: Option, + /// Free/busy indicator + pub free_busy: Option, +} + +impl CalendarEventRsvp { + /// Create a new calendar event RSVP + pub fn new( + id: S, + coordinate: Coordinate, + status: CalendarEventRsvpStatus, + ) -> Self + where + S: Into, + { + Self { + id: id.into(), + coordinate: (coordinate, None), + status, + event_id: None, + free_busy: None, + } + } +} + +impl From for Vec { + fn from(rsvp: CalendarEventRsvp) -> Self { + let CalendarEventRsvp { + id, + coordinate, + status, + event_id, + free_busy, + } = rsvp; + + let mut tags = Vec::new(); + + tags.push(Tag::identifier(id)); + + let (coord, relay_url) = coordinate; + tags.push(Tag::from_standardized_without_cell( + TagStandard::Coordinate { + coordinate: coord, + relay_url, + uppercase: false, + }, + )); + + tags.push(Tag::custom( + TagKind::Status, + [status.as_str()], + )); + + if let Some(event_id) = event_id { + tags.push(Tag::from_standardized_without_cell(TagStandard::event( + event_id, + ))); + } + + if let Some(fb) = free_busy { + tags.push(Tag::custom(TagKind::custom(FB_STR), [fb.as_str()])); + } + + tags + } +} + +impl TryFrom> for CalendarEventRsvp { + type Error = Error; + + fn try_from(tags: Vec) -> Result { + let id: &str = tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or(Error::IdentifierMissing)?; + + let mut rsvp = CalendarEventRsvp { + id: id.to_string(), + coordinate: ( + Coordinate { + kind: crate::Kind::Custom(0), + public_key: PublicKey::from_slice(&[0; 32]) + .expect("valid zero pubkey for placeholder"), + identifier: String::new(), + }, + None, + ), + status: CalendarEventRsvpStatus::Accepted, + event_id: None, + free_busy: None, + }; + + let mut has_coordinate = false; + let mut has_status = false; + + for tag in tags.into_iter() { + match tag.kind() { + TagKind::Status => { + if let Some(content) = tag.content() { + if let Ok(s) = CalendarEventRsvpStatus::from_str(content) { + rsvp.status = s; + has_status = true; + } + } + } + TagKind::Custom(ref s) if s.as_ref() == FB_STR => { + if let Some(content) = tag.content() { + if let Ok(fb) = FreeBusy::from_str(content) { + rsvp.free_busy = Some(fb); + } + } + } + _ => { + if let Some(std_tag) = tag.to_standardized() { + match std_tag { + TagStandard::Coordinate { + coordinate, + relay_url, + uppercase: false, + } => { + if !has_coordinate { + rsvp.coordinate = (coordinate, relay_url); + has_coordinate = true; + } + } + TagStandard::Event { + event_id, + uppercase: false, + .. + } => { + rsvp.event_id = Some(event_id); + } + _ => {} + } + } + } + } + } + + if !has_coordinate { + return Err(Error::CoordinateMissing); + } + if !has_status { + return Err(Error::StatusMissing); + } + + Ok(rsvp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Kind; + + fn test_pubkey() -> PublicKey { + PublicKey::from_str("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") + .unwrap() + } + + #[test] + fn test_date_based_calendar_event_round_trip() { + let event = DateBasedCalendarEvent { + id: "poker-night".to_string(), + title: "Poker Night".to_string(), + start: "2023-12-25".to_string(), + end: Some("2023-12-26".to_string()), + summary: Some("A fun poker night".to_string()), + image: None, + location: Some("The Pub".to_string()), + geohash: Some("u4pruydqqvj".to_string()), + participants: vec![(test_pubkey(), None, Some("dealer".to_string()))], + hashtags: vec!["poker".to_string()], + references: vec!["https://example.com".to_string()], + coordinates: Vec::new(), + }; + + let tags: Vec = event.clone().into(); + let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); + + assert_eq!(parsed, event); + } + + #[test] + fn test_date_based_calendar_event_minimal() { + let event = DateBasedCalendarEvent::new("meeting", "Team Meeting", "2024-01-15"); + + let tags: Vec = event.clone().into(); + let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); + + assert_eq!(parsed, event); + } + + #[test] + fn test_date_based_calendar_event_missing_title() { + let tags = vec![ + Tag::identifier("test"), + Tag::custom(TagKind::Start, ["2024-01-01"]), + ]; + let result = DateBasedCalendarEvent::try_from(tags); + assert_eq!(result.unwrap_err(), Error::TitleMissing); + } + + #[test] + fn test_date_based_calendar_event_missing_start() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + ]; + let result = DateBasedCalendarEvent::try_from(tags); + assert_eq!(result.unwrap_err(), Error::StartMissing); + } + + #[test] + fn test_time_based_calendar_event_round_trip() { + let event = TimeBasedCalendarEvent { + id: "meetup-123".to_string(), + title: "Nostr Meetup".to_string(), + start: Timestamp::from(1700000000), + end: Some(Timestamp::from(1700003600)), + start_tzid: Some("America/New_York".to_string()), + end_tzid: Some("America/New_York".to_string()), + summary: Some("Monthly nostr meetup".to_string()), + image: None, + location: Some("NYC Hackerspace".to_string()), + geohash: None, + participants: vec![(test_pubkey(), None, Some("organizer".to_string()))], + hashtags: vec!["nostr".to_string()], + references: Vec::new(), + coordinates: Vec::new(), + }; + + let tags: Vec = event.clone().into(); + let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); + + assert_eq!(parsed, event); + } + + #[test] + fn test_time_based_calendar_event_minimal() { + let event = TimeBasedCalendarEvent::new("event-1", "Quick Chat", Timestamp::from(1700000000)); + + let tags: Vec = event.clone().into(); + let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); + + assert_eq!(parsed, event); + } + + #[test] + fn test_calendar_round_trip() { + let coord = Coordinate { + kind: Kind::DateBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "poker-night".to_string(), + }; + + let calendar = Calendar { + id: "my-calendar".to_string(), + title: "My Calendar".to_string(), + coordinates: vec![(coord, None)], + }; + + let tags: Vec = calendar.clone().into(); + let parsed = Calendar::try_from(tags).unwrap(); + + assert_eq!(parsed, calendar); + } + + #[test] + fn test_calendar_missing_title() { + let tags = vec![Tag::identifier("test")]; + let result = Calendar::try_from(tags); + assert_eq!(result.unwrap_err(), Error::TitleMissing); + } + + #[test] + fn test_rsvp_round_trip() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "meetup-123".to_string(), + }; + + let rsvp = CalendarEventRsvp { + id: "31923:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245:meetup-123".to_string(), + coordinate: (coord, None), + status: CalendarEventRsvpStatus::Accepted, + event_id: None, + free_busy: Some(FreeBusy::Busy), + }; + + let tags: Vec = rsvp.clone().into(); + let parsed = CalendarEventRsvp::try_from(tags).unwrap(); + + assert_eq!(parsed, rsvp); + } + + #[test] + fn test_rsvp_all_statuses() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "event".to_string(), + }; + + for status in [ + CalendarEventRsvpStatus::Accepted, + CalendarEventRsvpStatus::Declined, + CalendarEventRsvpStatus::Tentative, + ] { + let rsvp = CalendarEventRsvp::new("test", coord.clone(), status.clone()); + let tags: Vec = rsvp.clone().into(); + let parsed = CalendarEventRsvp::try_from(tags).unwrap(); + assert_eq!(parsed.status, status); + } + } + + #[test] + fn test_rsvp_missing_coordinate() { + let tags = vec![ + Tag::identifier("test"), + Tag::custom(TagKind::Status, ["accepted"]), + ]; + let result = CalendarEventRsvp::try_from(tags); + assert_eq!(result.unwrap_err(), Error::CoordinateMissing); + } + + #[test] + fn test_rsvp_missing_status() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "event".to_string(), + }; + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Coordinate { + coordinate: coord, + relay_url: None, + uppercase: false, + }), + ]; + let result = CalendarEventRsvp::try_from(tags); + assert_eq!(result.unwrap_err(), Error::StatusMissing); + } + + #[test] + fn test_rsvp_status_parsing() { + assert_eq!( + CalendarEventRsvpStatus::from_str("accepted").unwrap(), + CalendarEventRsvpStatus::Accepted + ); + assert_eq!( + CalendarEventRsvpStatus::from_str("declined").unwrap(), + CalendarEventRsvpStatus::Declined + ); + assert_eq!( + CalendarEventRsvpStatus::from_str("tentative").unwrap(), + CalendarEventRsvpStatus::Tentative + ); + assert!(CalendarEventRsvpStatus::from_str("unknown").is_err()); + } + + #[test] + fn test_free_busy_parsing() { + assert_eq!(FreeBusy::from_str("free").unwrap(), FreeBusy::Free); + assert_eq!(FreeBusy::from_str("busy").unwrap(), FreeBusy::Busy); + assert!(FreeBusy::from_str("unknown").is_err()); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 02c020588..d0de98979 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -61,6 +61,7 @@ pub use crate::nips::nip48::{self, *}; #[cfg(feature = "nip49")] pub use crate::nips::nip49::{self, *}; pub use crate::nips::nip51::{self, *}; +pub use crate::nips::nip52::{self, *}; pub use crate::nips::nip53::{self, *}; pub use crate::nips::nip56::{self, *}; #[cfg(feature = "nip57")] From e2e05375677ffb35ffedb08008743270974e8bcb Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:08:54 +0000 Subject: [PATCH 2/8] nip52: improve tests --- crates/nostr/src/nips/nip52.rs | 225 +++++++++++++++++++++++++++++---- 1 file changed, 202 insertions(+), 23 deletions(-) diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index 0fb00d34f..240c4d88a 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -916,8 +916,20 @@ mod tests { }; let tags: Vec = event.clone().into(); - let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); + // Verify serialized tag count and key values + // d, title, start, end, summary, location, geohash, p, t, r = 10 + assert_eq!(tags.len(), 10); + assert_eq!(tags[0].kind(), TagKind::d()); + assert_eq!(tags[0].content(), Some("poker-night")); + assert_eq!(tags[1].kind(), TagKind::Title); + assert_eq!(tags[1].content(), Some("Poker Night")); + assert_eq!(tags[2].kind(), TagKind::Start); + assert_eq!(tags[2].content(), Some("2023-12-25")); + assert_eq!(tags[3].kind(), TagKind::End); + assert_eq!(tags[3].content(), Some("2023-12-26")); + + let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); assert_eq!(parsed, event); } @@ -971,8 +983,19 @@ mod tests { }; let tags: Vec = event.clone().into(); - let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); + // d, title, start, end, start_tzid, end_tzid, summary, location, p, t = 10 + assert_eq!(tags.len(), 10); + assert_eq!(tags[0].kind(), TagKind::d()); + assert_eq!(tags[0].content(), Some("meetup-123")); + assert_eq!(tags[1].kind(), TagKind::Title); + assert_eq!(tags[1].content(), Some("Nostr Meetup")); + assert_eq!(tags[2].kind(), TagKind::Start); + assert_eq!(tags[2].content(), Some("1700000000")); + assert_eq!(tags[3].kind(), TagKind::End); + assert_eq!(tags[3].content(), Some("1700003600")); + + let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); assert_eq!(parsed, event); } @@ -981,8 +1004,17 @@ mod tests { let event = TimeBasedCalendarEvent::new("event-1", "Quick Chat", Timestamp::from(1700000000)); let tags: Vec = event.clone().into(); - let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); + // d, title, start = 3 tags + assert_eq!(tags.len(), 3); + assert_eq!(tags[0].kind(), TagKind::d()); + assert_eq!(tags[0].content(), Some("event-1")); + assert_eq!(tags[1].kind(), TagKind::Title); + assert_eq!(tags[1].content(), Some("Quick Chat")); + assert_eq!(tags[2].kind(), TagKind::Start); + assert_eq!(tags[2].content(), Some("1700000000")); + + let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); assert_eq!(parsed, event); } @@ -1035,26 +1067,6 @@ mod tests { assert_eq!(parsed, rsvp); } - #[test] - fn test_rsvp_all_statuses() { - let coord = Coordinate { - kind: Kind::TimeBasedCalendarEvent, - public_key: test_pubkey(), - identifier: "event".to_string(), - }; - - for status in [ - CalendarEventRsvpStatus::Accepted, - CalendarEventRsvpStatus::Declined, - CalendarEventRsvpStatus::Tentative, - ] { - let rsvp = CalendarEventRsvp::new("test", coord.clone(), status.clone()); - let tags: Vec = rsvp.clone().into(); - let parsed = CalendarEventRsvp::try_from(tags).unwrap(); - assert_eq!(parsed.status, status); - } - } - #[test] fn test_rsvp_missing_coordinate() { let tags = vec![ @@ -1107,4 +1119,171 @@ mod tests { assert_eq!(FreeBusy::from_str("busy").unwrap(), FreeBusy::Busy); assert!(FreeBusy::from_str("unknown").is_err()); } + + #[test] + fn test_rsvp_status_serialized_values() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "event".to_string(), + }; + + for (status, expected_str) in [ + (CalendarEventRsvpStatus::Accepted, "accepted"), + (CalendarEventRsvpStatus::Declined, "declined"), + (CalendarEventRsvpStatus::Tentative, "tentative"), + ] { + let rsvp = CalendarEventRsvp::new("test", coord.clone(), status); + let tags: Vec = rsvp.into(); + + let status_tag = tags + .iter() + .find(|t| t.kind() == TagKind::Status) + .expect("status tag must exist"); + assert_eq!(status_tag.content(), Some(expected_str)); + } + } + + #[test] + fn test_rsvp_round_trip_free() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "meetup-123".to_string(), + }; + + let rsvp = CalendarEventRsvp { + id: "rsvp-free".to_string(), + coordinate: (coord, None), + status: CalendarEventRsvpStatus::Accepted, + event_id: None, + free_busy: Some(FreeBusy::Free), + }; + + let tags: Vec = rsvp.clone().into(); + let parsed = CalendarEventRsvp::try_from(tags).unwrap(); + assert_eq!(parsed, rsvp); + } + + #[test] + fn test_date_based_missing_identifier() { + let tags = vec![ + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["2024-01-01"]), + ]; + assert_eq!( + DateBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::IdentifierMissing + ); + } + + #[test] + fn test_time_based_missing_identifier() { + let tags = vec![ + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["1700000000"]), + ]; + assert_eq!( + TimeBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::IdentifierMissing + ); + } + + #[test] + fn test_time_based_missing_title() { + let tags = vec![ + Tag::identifier("test"), + Tag::custom(TagKind::Start, ["1700000000"]), + ]; + assert_eq!( + TimeBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::TitleMissing + ); + } + + #[test] + fn test_time_based_missing_start() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + ]; + assert_eq!( + TimeBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::StartMissing + ); + } + + #[test] + fn test_time_based_unparseable_start() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["not-a-timestamp"]), + ]; + // Unparseable timestamp is treated as missing (M4 notes this) + assert_eq!( + TimeBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::StartMissing + ); + } + + #[test] + fn test_date_based_ignores_unknown_tags() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["2024-01-01"]), + Tag::custom(TagKind::custom("unknown_tag"), ["some_value"]), + Tag::custom(TagKind::custom("another"), ["a", "b", "c"]), + ]; + let event = DateBasedCalendarEvent::try_from(tags).unwrap(); + assert_eq!(event.id, "test"); + assert_eq!(event.title, "Test"); + assert_eq!(event.start, "2024-01-01"); + } + + #[test] + fn test_time_based_ignores_unknown_tags() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["1700000000"]), + Tag::custom(TagKind::custom("foo"), ["bar"]), + ]; + let event = TimeBasedCalendarEvent::try_from(tags).unwrap(); + assert_eq!(event.id, "test"); + assert_eq!(event.start, Timestamp::from(1700000000)); + } + + #[test] + fn test_calendar_missing_identifier() { + let tags = vec![Tag::from_standardized_without_cell(TagStandard::Title( + "Test".to_string(), + ))]; + assert_eq!( + Calendar::try_from(tags).unwrap_err(), + Error::IdentifierMissing + ); + } + + #[test] + fn test_rsvp_missing_identifier() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "event".to_string(), + }; + let tags = vec![ + Tag::from_standardized_without_cell(TagStandard::Coordinate { + coordinate: coord, + relay_url: None, + uppercase: false, + }), + Tag::custom(TagKind::Status, ["accepted"]), + ]; + assert_eq!( + CalendarEventRsvp::try_from(tags).unwrap_err(), + Error::IdentifierMissing + ); + } } From 732c67dd2569efc8bb4224ea52b819c6d42bc62e Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:16:04 +0000 Subject: [PATCH 3/8] nip52: fix critical spec violations (D tag, repeatable location, RSVP author) - Add uppercase D tags to TimeBasedCalendarEvent for day-granularity relay filtering - Change location from Option to Vec (spec says "repeated") - Add optional author (p tag) to CalendarEventRsvp for event author pubkey - Clean up RSVP deserialization to use Option instead of placeholder Coordinate --- crates/nostr/src/nips/nip52.rs | 147 ++++++++++++++++++++------------- 1 file changed, 89 insertions(+), 58 deletions(-) diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index 240c4d88a..0e6eae399 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -13,7 +13,7 @@ use core::str::FromStr; use crate::nips::nip01::Coordinate; use crate::types::{RelayUrl, Url}; -use crate::{EventId, ImageDimensions, PublicKey, Tag, TagKind, TagStandard, Timestamp}; +use crate::{Alphabet, EventId, ImageDimensions, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp}; const START_TZID_STR: &str = "start_tzid"; const END_TZID_STR: &str = "end_tzid"; @@ -147,8 +147,8 @@ pub struct DateBasedCalendarEvent { pub summary: Option, /// Image pub image: Option<(Url, Option)>, - /// Location - pub location: Option, + /// Locations (repeatable) + pub locations: Vec, /// Geohash pub geohash: Option, /// Participants (pubkey, optional relay URL, optional role) @@ -176,7 +176,7 @@ impl DateBasedCalendarEvent { end: None, summary: None, image: None, - location: None, + locations: Vec::new(), geohash: None, participants: Vec::new(), hashtags: Vec::new(), @@ -195,7 +195,7 @@ impl From for Vec { end, summary, image, - location, + locations, geohash, participants, hashtags, @@ -227,7 +227,7 @@ impl From for Vec { ))); } - if let Some(location) = location { + for location in locations { tags.push(Tag::from_standardized_without_cell(TagStandard::Location( location, ))); @@ -291,7 +291,7 @@ impl TryFrom> for DateBasedCalendarEvent { end: None, summary: None, image: None, - location: None, + locations: Vec::new(), geohash: None, participants: Vec::new(), hashtags: Vec::new(), @@ -324,7 +324,7 @@ impl TryFrom> for DateBasedCalendarEvent { } TagStandard::Summary(summary) => event.summary = Some(summary), TagStandard::Image(url, dim) => event.image = Some((url, dim)), - TagStandard::Location(loc) => event.location = Some(loc), + TagStandard::Location(loc) => event.locations.push(loc), TagStandard::Geohash(g) => event.geohash = Some(g), TagStandard::PublicKey { public_key, @@ -382,8 +382,8 @@ pub struct TimeBasedCalendarEvent { pub summary: Option, /// Image pub image: Option<(Url, Option)>, - /// Location - pub location: Option, + /// Locations (repeatable) + pub locations: Vec, /// Geohash pub geohash: Option, /// Participants (pubkey, optional relay URL, optional role) @@ -412,7 +412,7 @@ impl TimeBasedCalendarEvent { end_tzid: None, summary: None, image: None, - location: None, + locations: Vec::new(), geohash: None, participants: Vec::new(), hashtags: Vec::new(), @@ -433,7 +433,7 @@ impl From for Vec { end_tzid, summary, image, - location, + locations, geohash, participants, hashtags, @@ -453,6 +453,20 @@ impl From for Vec { tags.push(Tag::custom(TagKind::End, [end.to_string()])); } + // D tags: day-granularity timestamps for relay filtering + const SECONDS_PER_DAY: u64 = 86400; + let d_upper = TagKind::SingleLetter(SingleLetterTag { + character: Alphabet::D, + uppercase: true, + }); + let start_day = start.as_secs() / SECONDS_PER_DAY; + let end_day = end + .map(|e| e.as_secs() / SECONDS_PER_DAY) + .unwrap_or(start_day); + for day in start_day..=end_day { + tags.push(Tag::custom(d_upper.clone(), [day.to_string()])); + } + if let Some(tzid) = start_tzid { tags.push(Tag::custom(TagKind::custom(START_TZID_STR), [tzid])); } @@ -473,7 +487,7 @@ impl From for Vec { ))); } - if let Some(location) = location { + for location in locations { tags.push(Tag::from_standardized_without_cell(TagStandard::Location( location, ))); @@ -539,7 +553,7 @@ impl TryFrom> for TimeBasedCalendarEvent { end_tzid: None, summary: None, image: None, - location: None, + locations: Vec::new(), geohash: None, participants: Vec::new(), hashtags: Vec::new(), @@ -586,7 +600,7 @@ impl TryFrom> for TimeBasedCalendarEvent { } TagStandard::Summary(summary) => event.summary = Some(summary), TagStandard::Image(url, dim) => event.image = Some((url, dim)), - TagStandard::Location(loc) => event.location = Some(loc), + TagStandard::Location(loc) => event.locations.push(loc), TagStandard::Geohash(g) => event.geohash = Some(g), TagStandard::PublicKey { public_key, @@ -736,6 +750,8 @@ pub struct CalendarEventRsvp { pub coordinate: (Coordinate, Option), /// RSVP status pub status: CalendarEventRsvpStatus, + /// Calendar event author pubkey (`p` tag, optional) + pub author: Option, /// Optional event ID (`e` tag, specific revision) pub event_id: Option, /// Free/busy indicator @@ -756,6 +772,7 @@ impl CalendarEventRsvp { id: id.into(), coordinate: (coordinate, None), status, + author: None, event_id: None, free_busy: None, } @@ -768,6 +785,7 @@ impl From for Vec { id, coordinate, status, + author, event_id, free_busy, } = rsvp; @@ -790,6 +808,15 @@ impl From for Vec { [status.as_str()], )); + if let Some(author) = author { + tags.push(Tag::from_standardized_without_cell(TagStandard::PublicKey { + public_key: author, + relay_url: None, + alias: None, + uppercase: false, + })); + } + if let Some(event_id) = event_id { tags.push(Tag::from_standardized_without_cell(TagStandard::event( event_id, @@ -808,45 +835,32 @@ impl TryFrom> for CalendarEventRsvp { type Error = Error; fn try_from(tags: Vec) -> Result { - let id: &str = tags + let id: String = tags .iter() .find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) - .ok_or(Error::IdentifierMissing)?; - - let mut rsvp = CalendarEventRsvp { - id: id.to_string(), - coordinate: ( - Coordinate { - kind: crate::Kind::Custom(0), - public_key: PublicKey::from_slice(&[0; 32]) - .expect("valid zero pubkey for placeholder"), - identifier: String::new(), - }, - None, - ), - status: CalendarEventRsvpStatus::Accepted, - event_id: None, - free_busy: None, - }; + .ok_or(Error::IdentifierMissing)? + .to_string(); - let mut has_coordinate = false; - let mut has_status = false; + let mut coordinate: Option<(Coordinate, Option)> = None; + let mut status: Option = None; + let mut author: Option = None; + let mut event_id: Option = None; + let mut free_busy: Option = None; for tag in tags.into_iter() { match tag.kind() { TagKind::Status => { if let Some(content) = tag.content() { if let Ok(s) = CalendarEventRsvpStatus::from_str(content) { - rsvp.status = s; - has_status = true; + status = Some(s); } } } TagKind::Custom(ref s) if s.as_ref() == FB_STR => { if let Some(content) = tag.content() { if let Ok(fb) = FreeBusy::from_str(content) { - rsvp.free_busy = Some(fb); + free_busy = Some(fb); } } } @@ -854,21 +868,29 @@ impl TryFrom> for CalendarEventRsvp { if let Some(std_tag) = tag.to_standardized() { match std_tag { TagStandard::Coordinate { - coordinate, + coordinate: coord, relay_url, uppercase: false, } => { - if !has_coordinate { - rsvp.coordinate = (coordinate, relay_url); - has_coordinate = true; + if coordinate.is_none() { + coordinate = Some((coord, relay_url)); + } + } + TagStandard::PublicKey { + public_key, + uppercase: false, + .. + } => { + if author.is_none() { + author = Some(public_key); } } TagStandard::Event { - event_id, + event_id: eid, uppercase: false, .. } => { - rsvp.event_id = Some(event_id); + event_id = Some(eid); } _ => {} } @@ -877,14 +899,17 @@ impl TryFrom> for CalendarEventRsvp { } } - if !has_coordinate { - return Err(Error::CoordinateMissing); - } - if !has_status { - return Err(Error::StatusMissing); - } + let coordinate = coordinate.ok_or(Error::CoordinateMissing)?; + let status = status.ok_or(Error::StatusMissing)?; - Ok(rsvp) + Ok(CalendarEventRsvp { + id, + coordinate, + status, + author, + event_id, + free_busy, + }) } } @@ -907,7 +932,7 @@ mod tests { end: Some("2023-12-26".to_string()), summary: Some("A fun poker night".to_string()), image: None, - location: Some("The Pub".to_string()), + locations: vec!["The Pub".to_string()], geohash: Some("u4pruydqqvj".to_string()), participants: vec![(test_pubkey(), None, Some("dealer".to_string()))], hashtags: vec!["poker".to_string()], @@ -917,7 +942,6 @@ mod tests { let tags: Vec = event.clone().into(); - // Verify serialized tag count and key values // d, title, start, end, summary, location, geohash, p, t, r = 10 assert_eq!(tags.len(), 10); assert_eq!(tags[0].kind(), TagKind::d()); @@ -974,7 +998,7 @@ mod tests { end_tzid: Some("America/New_York".to_string()), summary: Some("Monthly nostr meetup".to_string()), image: None, - location: Some("NYC Hackerspace".to_string()), + locations: vec!["NYC Hackerspace".to_string()], geohash: None, participants: vec![(test_pubkey(), None, Some("organizer".to_string()))], hashtags: vec!["nostr".to_string()], @@ -984,8 +1008,8 @@ mod tests { let tags: Vec = event.clone().into(); - // d, title, start, end, start_tzid, end_tzid, summary, location, p, t = 10 - assert_eq!(tags.len(), 10); + // d, title, start, end, D (1 day), start_tzid, end_tzid, summary, location, p, t = 11 + assert_eq!(tags.len(), 11); assert_eq!(tags[0].kind(), TagKind::d()); assert_eq!(tags[0].content(), Some("meetup-123")); assert_eq!(tags[1].kind(), TagKind::Title); @@ -1005,14 +1029,19 @@ mod tests { let tags: Vec = event.clone().into(); - // d, title, start = 3 tags - assert_eq!(tags.len(), 3); + // d, title, start, D = 4 tags + assert_eq!(tags.len(), 4); assert_eq!(tags[0].kind(), TagKind::d()); assert_eq!(tags[0].content(), Some("event-1")); assert_eq!(tags[1].kind(), TagKind::Title); assert_eq!(tags[1].content(), Some("Quick Chat")); assert_eq!(tags[2].kind(), TagKind::Start); assert_eq!(tags[2].content(), Some("1700000000")); + // D tag with day-granularity timestamp + let d_upper = TagKind::single_letter(Alphabet::D, true); + assert_eq!(tags[3].kind(), d_upper); + let expected_day = (1700000000u64 / 86400).to_string(); + assert_eq!(tags[3].content(), Some(expected_day.as_str())); let parsed = TimeBasedCalendarEvent::try_from(tags).unwrap(); assert_eq!(parsed, event); @@ -1057,6 +1086,7 @@ mod tests { id: "31923:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245:meetup-123".to_string(), coordinate: (coord, None), status: CalendarEventRsvpStatus::Accepted, + author: None, event_id: None, free_busy: Some(FreeBusy::Busy), }; @@ -1156,6 +1186,7 @@ mod tests { id: "rsvp-free".to_string(), coordinate: (coord, None), status: CalendarEventRsvpStatus::Accepted, + author: None, event_id: None, free_busy: Some(FreeBusy::Free), }; From 763241b3b4480ef29f5235499fdd1888f83a4fe6 Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:28:15 +0000 Subject: [PATCH 4/8] nip52: fix remaining review issues (validation, errors, docs) - M1: clarify `coordinates` doc comments as optional - M2: validate YYYY-MM-DD format for DateBasedCalendarEvent start/end - M4: return InvalidTimestamp instead of misleading StartMissing - m3: document that event description lives in Nostr event content field - m4: implement std::error::Error behind std feature flag - Add tests for date validation, invalid timestamps, and error paths --- crates/nostr/src/nips/nip52.rs | 143 ++++++++++++++++++++++++++++++--- 1 file changed, 134 insertions(+), 9 deletions(-) diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index 0e6eae399..9f7c82fb5 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -15,6 +15,17 @@ use crate::nips::nip01::Coordinate; use crate::types::{RelayUrl, Url}; use crate::{Alphabet, EventId, ImageDimensions, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp}; +/// Check if a string matches YYYY-MM-DD format +fn is_valid_date_format(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[..4].iter().all(|b| b.is_ascii_digit()) + && bytes[5..7].iter().all(|b| b.is_ascii_digit()) + && bytes[8..10].iter().all(|b| b.is_ascii_digit()) +} + const START_TZID_STR: &str = "start_tzid"; const END_TZID_STR: &str = "end_tzid"; const FB_STR: &str = "fb"; @@ -36,6 +47,10 @@ pub enum Error { UnknownRsvpStatus(String), /// Unknown free/busy value UnknownFreeBusy(String), + /// Invalid date format (expected YYYY-MM-DD) + InvalidDateFormat(String), + /// Invalid timestamp + InvalidTimestamp(String), } impl fmt::Display for Error { @@ -48,10 +63,15 @@ impl fmt::Display for Error { Self::CoordinateMissing => f.write_str("Missing coordinate (a tag)"), Self::UnknownRsvpStatus(s) => write!(f, "Unknown RSVP status: {s}"), Self::UnknownFreeBusy(s) => write!(f, "Unknown free/busy value: {s}"), + Self::InvalidDateFormat(s) => write!(f, "Invalid date format (expected YYYY-MM-DD): {s}"), + Self::InvalidTimestamp(s) => write!(f, "Invalid timestamp: {s}"), } } } +#[cfg(feature = "std")] +impl std::error::Error for Error {} + /// Calendar Event RSVP Status #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum CalendarEventRsvpStatus { @@ -132,6 +152,9 @@ impl FromStr for FreeBusy { /// Date-Based Calendar Event (kind 31922) /// +/// The event description is stored in the Nostr event's `content` field +/// and is not modeled here (handled at the `Event` level). +/// /// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct DateBasedCalendarEvent { @@ -157,7 +180,7 @@ pub struct DateBasedCalendarEvent { pub hashtags: Vec, /// References pub references: Vec, - /// Coordinates (`a` tags) + /// Coordinates (`a` tags, optional) — references to kind:31924 calendars requesting inclusion pub coordinates: Vec<(Coordinate, Option)>, } @@ -306,12 +329,18 @@ impl TryFrom> for DateBasedCalendarEvent { match tag.kind() { TagKind::Start => { if let Some(content) = tag.content() { + if !is_valid_date_format(content) { + return Err(Error::InvalidDateFormat(content.to_string())); + } event.start = content.to_string(); has_start = true; } } TagKind::End => { if let Some(content) = tag.content() { + if !is_valid_date_format(content) { + return Err(Error::InvalidDateFormat(content.to_string())); + } event.end = Some(content.to_string()); } } @@ -363,6 +392,9 @@ impl TryFrom> for DateBasedCalendarEvent { /// Time-Based Calendar Event (kind 31923) /// +/// The event description is stored in the Nostr event's `content` field +/// and is not modeled here (handled at the `Event` level). +/// /// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct TimeBasedCalendarEvent { @@ -392,7 +424,7 @@ pub struct TimeBasedCalendarEvent { pub hashtags: Vec, /// References pub references: Vec, - /// Coordinates (`a` tags) + /// Coordinates (`a` tags, optional) — references to kind:31924 calendars requesting inclusion pub coordinates: Vec<(Coordinate, Option)>, } @@ -568,16 +600,20 @@ impl TryFrom> for TimeBasedCalendarEvent { match tag.kind() { TagKind::Start => { if let Some(content) = tag.content() { - if let Ok(ts) = Timestamp::from_str(content) { - event.start = ts; - has_start = true; + match Timestamp::from_str(content) { + Ok(ts) => { + event.start = ts; + has_start = true; + } + Err(_) => return Err(Error::InvalidTimestamp(content.to_string())), } } } TagKind::End => { if let Some(content) = tag.content() { - if let Ok(ts) = Timestamp::from_str(content) { - event.end = Some(ts); + match Timestamp::from_str(content) { + Ok(ts) => event.end = Some(ts), + Err(_) => return Err(Error::InvalidTimestamp(content.to_string())), } } } @@ -639,6 +675,9 @@ impl TryFrom> for TimeBasedCalendarEvent { /// Calendar (kind 31924) /// +/// The calendar description is stored in the Nostr event's `content` field +/// and is not modeled here (handled at the `Event` level). +/// /// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Calendar { @@ -741,6 +780,9 @@ impl TryFrom> for Calendar { /// Calendar Event RSVP (kind 31925) /// +/// The RSVP note is stored in the Nostr event's `content` field +/// and is not modeled here (handled at the `Event` level). +/// /// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CalendarEventRsvp { @@ -1251,10 +1293,9 @@ mod tests { Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), Tag::custom(TagKind::Start, ["not-a-timestamp"]), ]; - // Unparseable timestamp is treated as missing (M4 notes this) assert_eq!( TimeBasedCalendarEvent::try_from(tags).unwrap_err(), - Error::StartMissing + Error::InvalidTimestamp("not-a-timestamp".to_string()) ); } @@ -1286,6 +1327,90 @@ mod tests { assert_eq!(event.start, Timestamp::from(1700000000)); } + // M2: Date format validation for DateBasedCalendarEvent + + #[test] + fn test_date_based_invalid_start_format() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["not-a-date"]), + ]; + assert_eq!( + DateBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::InvalidDateFormat("not-a-date".to_string()) + ); + } + + #[test] + fn test_date_based_invalid_start_wrong_separator() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["2024/01/15"]), + ]; + assert_eq!( + DateBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::InvalidDateFormat("2024/01/15".to_string()) + ); + } + + #[test] + fn test_date_based_invalid_end_format() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["2024-01-15"]), + Tag::custom(TagKind::End, ["bad-end"]), + ]; + assert_eq!( + DateBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::InvalidDateFormat("bad-end".to_string()) + ); + } + + #[test] + fn test_date_based_valid_date_format() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["2024-01-15"]), + Tag::custom(TagKind::End, ["2024-01-16"]), + ]; + let event = DateBasedCalendarEvent::try_from(tags).unwrap(); + assert_eq!(event.start, "2024-01-15"); + assert_eq!(event.end, Some("2024-01-16".to_string())); + } + + // M4: Invalid timestamp should return InvalidTimestamp, not StartMissing + + #[test] + fn test_time_based_invalid_start_timestamp() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["not-a-timestamp"]), + ]; + assert_eq!( + TimeBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::InvalidTimestamp("not-a-timestamp".to_string()) + ); + } + + #[test] + fn test_time_based_invalid_end_timestamp() { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, ["1700000000"]), + Tag::custom(TagKind::End, ["garbage"]), + ]; + assert_eq!( + TimeBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::InvalidTimestamp("garbage".to_string()) + ); + } + #[test] fn test_calendar_missing_identifier() { let tags = vec![Tag::from_standardized_without_cell(TagStandard::Title( From 7d7bfcdf61c5769e6b824f8b4a7284968ec247bf Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:31:20 +0000 Subject: [PATCH 5/8] nip52: cargo fmt --- crates/nostr/src/nips/nip52.rs | 80 ++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index 9f7c82fb5..81e3fa5e6 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -13,7 +13,10 @@ use core::str::FromStr; use crate::nips::nip01::Coordinate; use crate::types::{RelayUrl, Url}; -use crate::{Alphabet, EventId, ImageDimensions, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp}; +use crate::{ + Alphabet, EventId, ImageDimensions, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, + Timestamp, +}; /// Check if a string matches YYYY-MM-DD format fn is_valid_date_format(s: &str) -> bool { @@ -63,7 +66,9 @@ impl fmt::Display for Error { Self::CoordinateMissing => f.write_str("Missing coordinate (a tag)"), Self::UnknownRsvpStatus(s) => write!(f, "Unknown RSVP status: {s}"), Self::UnknownFreeBusy(s) => write!(f, "Unknown free/busy value: {s}"), - Self::InvalidDateFormat(s) => write!(f, "Invalid date format (expected YYYY-MM-DD): {s}"), + Self::InvalidDateFormat(s) => { + write!(f, "Invalid date format (expected YYYY-MM-DD): {s}") + } Self::InvalidTimestamp(s) => write!(f, "Invalid timestamp: {s}"), } } @@ -263,12 +268,14 @@ impl From for Vec { } for (pubkey, relay_url, role) in participants { - tags.push(Tag::from_standardized_without_cell(TagStandard::PublicKey { - public_key: pubkey, - relay_url, - alias: role, - uppercase: false, - })); + tags.push(Tag::from_standardized_without_cell( + TagStandard::PublicKey { + public_key: pubkey, + relay_url, + alias: role, + uppercase: false, + }, + )); } for hashtag in hashtags { @@ -278,9 +285,9 @@ impl From for Vec { } for reference in references { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Reference(reference), - )); + tags.push(Tag::from_standardized_without_cell(TagStandard::Reference( + reference, + ))); } for (coordinate, relay_url) in coordinates { @@ -532,12 +539,14 @@ impl From for Vec { } for (pubkey, relay_url, role) in participants { - tags.push(Tag::from_standardized_without_cell(TagStandard::PublicKey { - public_key: pubkey, - relay_url, - alias: role, - uppercase: false, - })); + tags.push(Tag::from_standardized_without_cell( + TagStandard::PublicKey { + public_key: pubkey, + relay_url, + alias: role, + uppercase: false, + }, + )); } for hashtag in hashtags { @@ -547,9 +556,9 @@ impl From for Vec { } for reference in references { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Reference(reference), - )); + tags.push(Tag::from_standardized_without_cell(TagStandard::Reference( + reference, + ))); } for (coordinate, relay_url) in coordinates { @@ -802,11 +811,7 @@ pub struct CalendarEventRsvp { impl CalendarEventRsvp { /// Create a new calendar event RSVP - pub fn new( - id: S, - coordinate: Coordinate, - status: CalendarEventRsvpStatus, - ) -> Self + pub fn new(id: S, coordinate: Coordinate, status: CalendarEventRsvpStatus) -> Self where S: Into, { @@ -845,18 +850,17 @@ impl From for Vec { }, )); - tags.push(Tag::custom( - TagKind::Status, - [status.as_str()], - )); + tags.push(Tag::custom(TagKind::Status, [status.as_str()])); if let Some(author) = author { - tags.push(Tag::from_standardized_without_cell(TagStandard::PublicKey { - public_key: author, - relay_url: None, - alias: None, - uppercase: false, - })); + tags.push(Tag::from_standardized_without_cell( + TagStandard::PublicKey { + public_key: author, + relay_url: None, + alias: None, + uppercase: false, + }, + )); } if let Some(event_id) = event_id { @@ -1067,7 +1071,8 @@ mod tests { #[test] fn test_time_based_calendar_event_minimal() { - let event = TimeBasedCalendarEvent::new("event-1", "Quick Chat", Timestamp::from(1700000000)); + let event = + TimeBasedCalendarEvent::new("event-1", "Quick Chat", Timestamp::from(1700000000)); let tags: Vec = event.clone().into(); @@ -1125,7 +1130,8 @@ mod tests { }; let rsvp = CalendarEventRsvp { - id: "31923:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245:meetup-123".to_string(), + id: "31923:32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245:meetup-123" + .to_string(), coordinate: (coord, None), status: CalendarEventRsvpStatus::Accepted, author: None, From 46cada1064cb7abcda4170ab89e8de24a564466b Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:43:56 +0000 Subject: [PATCH 6/8] nip52: improve consistency with the rest of the repo; fixes - Add EventBuilder convenience methods for all 4 NIP-52 event kinds - Replace Vec::new() with Vec::with_capacity() in tag serialization - Remove std::error::Error impl to match NIP-53 consistency - Fix RSVP status/FreeBusy parsing to return errors instead of silently skipping - Add comments explaining uppercase:false match arms - Add tests for multi-day D tags, RSVP with all fields, calendar with multiple coordinates, and unknown RSVP status error --- crates/nostr/src/event/builder.rs | 36 +++++++ crates/nostr/src/nips/nip52.rs | 174 ++++++++++++++++++++++++++---- 2 files changed, 192 insertions(+), 18 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 664769ec2..32b03fc20 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1775,6 +1775,42 @@ impl EventBuilder { ))) } + /// Date-Based Calendar Event + /// + /// + #[inline] + pub fn date_based_calendar_event(event: DateBasedCalendarEvent) -> Self { + let tags: Vec = event.into(); + Self::new(Kind::DateBasedCalendarEvent, "").tags(tags) + } + + /// Time-Based Calendar Event + /// + /// + #[inline] + pub fn time_based_calendar_event(event: TimeBasedCalendarEvent) -> Self { + let tags: Vec = event.into(); + Self::new(Kind::TimeBasedCalendarEvent, "").tags(tags) + } + + /// Calendar + /// + /// + #[inline] + pub fn calendar(calendar: Calendar) -> Self { + let tags: Vec = calendar.into(); + Self::new(Kind::Calendar, "").tags(tags) + } + + /// Calendar Event RSVP + /// + /// + #[inline] + pub fn calendar_event_rsvp(rsvp: CalendarEventRsvp) -> Self { + let tags: Vec = rsvp.into(); + Self::new(Kind::CalendarEventRsvp, "").tags(tags) + } + /// Label /// /// diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index 81e3fa5e6..5ad63cdf1 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -74,9 +74,6 @@ impl fmt::Display for Error { } } -#[cfg(feature = "std")] -impl std::error::Error for Error {} - /// Calendar Event RSVP Status #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum CalendarEventRsvpStatus { @@ -231,7 +228,17 @@ impl From for Vec { coordinates, } = event; - let mut tags = Vec::new(); + let mut tags = Vec::with_capacity( + 3 + usize::from(end.is_some()) + + usize::from(summary.is_some()) + + usize::from(image.is_some()) + + locations.len() + + usize::from(geohash.is_some()) + + participants.len() + + hashtags.len() + + references.len() + + coordinates.len(), + ); tags.push(Tag::identifier(id)); tags.push(Tag::from_standardized_without_cell(TagStandard::Title( @@ -362,6 +369,7 @@ impl TryFrom> for DateBasedCalendarEvent { TagStandard::Image(url, dim) => event.image = Some((url, dim)), TagStandard::Location(loc) => event.locations.push(loc), TagStandard::Geohash(g) => event.geohash = Some(g), + // NIP-52 uses lowercase `p` tags; uppercase `P` tags are ignored TagStandard::PublicKey { public_key, relay_url, @@ -372,6 +380,7 @@ impl TryFrom> for DateBasedCalendarEvent { } TagStandard::Hashtag(h) => event.hashtags.push(h), TagStandard::Reference(r) => event.references.push(r), + // NIP-52 uses lowercase `a` tags; uppercase `A` tags are ignored TagStandard::Coordinate { coordinate, relay_url, @@ -480,7 +489,28 @@ impl From for Vec { coordinates, } = event; - let mut tags = Vec::new(); + // Calculate D tag count before allocating + const SECONDS_PER_DAY: u64 = 86400; + let start_day = start.as_secs() / SECONDS_PER_DAY; + let end_day = end + .map(|e| e.as_secs() / SECONDS_PER_DAY) + .unwrap_or(start_day); + let d_tag_count = (end_day - start_day + 1) as usize; + + let mut tags = Vec::with_capacity( + 3 + usize::from(end.is_some()) + + d_tag_count + + usize::from(start_tzid.is_some()) + + usize::from(end_tzid.is_some()) + + usize::from(summary.is_some()) + + usize::from(image.is_some()) + + locations.len() + + usize::from(geohash.is_some()) + + participants.len() + + hashtags.len() + + references.len() + + coordinates.len(), + ); tags.push(Tag::identifier(id)); tags.push(Tag::from_standardized_without_cell(TagStandard::Title( @@ -493,15 +523,10 @@ impl From for Vec { } // D tags: day-granularity timestamps for relay filtering - const SECONDS_PER_DAY: u64 = 86400; let d_upper = TagKind::SingleLetter(SingleLetterTag { character: Alphabet::D, uppercase: true, }); - let start_day = start.as_secs() / SECONDS_PER_DAY; - let end_day = end - .map(|e| e.as_secs() / SECONDS_PER_DAY) - .unwrap_or(start_day); for day in start_day..=end_day { tags.push(Tag::custom(d_upper.clone(), [day.to_string()])); } @@ -647,6 +672,7 @@ impl TryFrom> for TimeBasedCalendarEvent { TagStandard::Image(url, dim) => event.image = Some((url, dim)), TagStandard::Location(loc) => event.locations.push(loc), TagStandard::Geohash(g) => event.geohash = Some(g), + // NIP-52 uses lowercase `p` tags; uppercase `P` tags are ignored TagStandard::PublicKey { public_key, relay_url, @@ -657,6 +683,7 @@ impl TryFrom> for TimeBasedCalendarEvent { } TagStandard::Hashtag(h) => event.hashtags.push(h), TagStandard::Reference(r) => event.references.push(r), + // NIP-52 uses lowercase `a` tags; uppercase `A` tags are ignored TagStandard::Coordinate { coordinate, relay_url, @@ -721,7 +748,7 @@ impl From for Vec { coordinates, } = calendar; - let mut tags = Vec::new(); + let mut tags = Vec::with_capacity(2 + coordinates.len()); tags.push(Tag::identifier(id)); tags.push(Tag::from_standardized_without_cell(TagStandard::Title( @@ -837,7 +864,11 @@ impl From for Vec { free_busy, } = rsvp; - let mut tags = Vec::new(); + let mut tags = Vec::with_capacity( + 3 + usize::from(author.is_some()) + + usize::from(event_id.is_some()) + + usize::from(free_busy.is_some()), + ); tags.push(Tag::identifier(id)); @@ -898,16 +929,12 @@ impl TryFrom> for CalendarEventRsvp { match tag.kind() { TagKind::Status => { if let Some(content) = tag.content() { - if let Ok(s) = CalendarEventRsvpStatus::from_str(content) { - status = Some(s); - } + status = Some(CalendarEventRsvpStatus::from_str(content)?); } } TagKind::Custom(ref s) if s.as_ref() == FB_STR => { if let Some(content) = tag.content() { - if let Ok(fb) = FreeBusy::from_str(content) { - free_busy = Some(fb); - } + free_busy = Some(FreeBusy::from_str(content)?); } } _ => { @@ -1448,4 +1475,115 @@ mod tests { Error::IdentifierMissing ); } + + #[test] + fn test_time_based_multi_day_d_tags() { + // Event spanning 3 days + let start = Timestamp::from(1700000000); // day 19675 + let end = Timestamp::from(1700000000 + 2 * 86400); // day 19677 + let event = TimeBasedCalendarEvent { + id: "multi-day".to_string(), + title: "Conference".to_string(), + start, + end: Some(end), + start_tzid: None, + end_tzid: None, + summary: None, + image: None, + locations: Vec::new(), + geohash: None, + participants: Vec::new(), + hashtags: Vec::new(), + references: Vec::new(), + coordinates: Vec::new(), + }; + + let tags: Vec = event.into(); + let d_upper = TagKind::single_letter(Alphabet::D, true); + let d_tags: Vec<&Tag> = tags.iter().filter(|t| t.kind() == d_upper).collect(); + assert_eq!(d_tags.len(), 3); + + let start_day = 1700000000u64 / 86400; + assert_eq!(d_tags[0].content(), Some(start_day.to_string().as_str())); + assert_eq!( + d_tags[1].content(), + Some((start_day + 1).to_string().as_str()) + ); + assert_eq!( + d_tags[2].content(), + Some((start_day + 2).to_string().as_str()) + ); + } + + #[test] + fn test_rsvp_with_all_fields() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "meetup-123".to_string(), + }; + let event_id = EventId::all_zeros(); + + let rsvp = CalendarEventRsvp { + id: "rsvp-full".to_string(), + coordinate: (coord, None), + status: CalendarEventRsvpStatus::Tentative, + author: Some(test_pubkey()), + event_id: Some(event_id), + free_busy: Some(FreeBusy::Busy), + }; + + let tags: Vec = rsvp.clone().into(); + let parsed = CalendarEventRsvp::try_from(tags).unwrap(); + assert_eq!(parsed, rsvp); + } + + #[test] + fn test_calendar_with_multiple_coordinates() { + let coords: Vec<(Coordinate, Option)> = (0..3) + .map(|i| { + ( + Coordinate { + kind: Kind::DateBasedCalendarEvent, + public_key: test_pubkey(), + identifier: format!("event-{i}"), + }, + None, + ) + }) + .collect(); + + let calendar = Calendar { + id: "my-cal".to_string(), + title: "My Calendar".to_string(), + coordinates: coords, + }; + + let tags: Vec = calendar.clone().into(); + let parsed = Calendar::try_from(tags).unwrap(); + assert_eq!(parsed, calendar); + assert_eq!(parsed.coordinates.len(), 3); + } + + #[test] + fn test_rsvp_unknown_status_returns_error() { + let coord = Coordinate { + kind: Kind::TimeBasedCalendarEvent, + public_key: test_pubkey(), + identifier: "event".to_string(), + }; + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Coordinate { + coordinate: coord, + relay_url: None, + uppercase: false, + }), + Tag::custom(TagKind::Status, ["maybe"]), + ]; + assert_eq!( + CalendarEventRsvp::try_from(tags).unwrap_err(), + Error::UnknownRsvpStatus("maybe".to_string()) + ); + } } From 4bb563f7ebb18ed7b8b6fb80ed54bc40a5f2434b Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:55:59 +0000 Subject: [PATCH 7/8] =?UTF-8?q?nip52:=20clean=20up=20test=20suite=20?= =?UTF-8?q?=E2=80=94=20remove=20duplicates,=20merge=20near-duplicates,=20a?= =?UTF-8?q?dd=20coverage=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/nostr/src/nips/nip52.rs | 169 +++++++++------------------------ 1 file changed, 44 insertions(+), 125 deletions(-) diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index 5ad63cdf1..dd08e6bb8 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -1035,6 +1035,7 @@ mod tests { let event = DateBasedCalendarEvent::new("meeting", "Team Meeting", "2024-01-15"); let tags: Vec = event.clone().into(); + assert_eq!(tags.len(), 3); // d, title, start — no optional fields let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); assert_eq!(parsed, event); @@ -1203,18 +1204,15 @@ mod tests { #[test] fn test_rsvp_status_parsing() { - assert_eq!( - CalendarEventRsvpStatus::from_str("accepted").unwrap(), - CalendarEventRsvpStatus::Accepted - ); - assert_eq!( - CalendarEventRsvpStatus::from_str("declined").unwrap(), - CalendarEventRsvpStatus::Declined - ); - assert_eq!( - CalendarEventRsvpStatus::from_str("tentative").unwrap(), - CalendarEventRsvpStatus::Tentative - ); + for (s, expected) in [ + ("accepted", CalendarEventRsvpStatus::Accepted), + ("declined", CalendarEventRsvpStatus::Declined), + ("tentative", CalendarEventRsvpStatus::Tentative), + ] { + let parsed = CalendarEventRsvpStatus::from_str(s).unwrap(); + assert_eq!(parsed, expected); + assert_eq!(parsed.as_str(), s); + } assert!(CalendarEventRsvpStatus::from_str("unknown").is_err()); } @@ -1225,52 +1223,6 @@ mod tests { assert!(FreeBusy::from_str("unknown").is_err()); } - #[test] - fn test_rsvp_status_serialized_values() { - let coord = Coordinate { - kind: Kind::TimeBasedCalendarEvent, - public_key: test_pubkey(), - identifier: "event".to_string(), - }; - - for (status, expected_str) in [ - (CalendarEventRsvpStatus::Accepted, "accepted"), - (CalendarEventRsvpStatus::Declined, "declined"), - (CalendarEventRsvpStatus::Tentative, "tentative"), - ] { - let rsvp = CalendarEventRsvp::new("test", coord.clone(), status); - let tags: Vec = rsvp.into(); - - let status_tag = tags - .iter() - .find(|t| t.kind() == TagKind::Status) - .expect("status tag must exist"); - assert_eq!(status_tag.content(), Some(expected_str)); - } - } - - #[test] - fn test_rsvp_round_trip_free() { - let coord = Coordinate { - kind: Kind::TimeBasedCalendarEvent, - public_key: test_pubkey(), - identifier: "meetup-123".to_string(), - }; - - let rsvp = CalendarEventRsvp { - id: "rsvp-free".to_string(), - coordinate: (coord, None), - status: CalendarEventRsvpStatus::Accepted, - author: None, - event_id: None, - free_busy: Some(FreeBusy::Free), - }; - - let tags: Vec = rsvp.clone().into(); - let parsed = CalendarEventRsvp::try_from(tags).unwrap(); - assert_eq!(parsed, rsvp); - } - #[test] fn test_date_based_missing_identifier() { let tags = vec![ @@ -1364,28 +1316,17 @@ mod tests { #[test] fn test_date_based_invalid_start_format() { - let tags = vec![ - Tag::identifier("test"), - Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), - Tag::custom(TagKind::Start, ["not-a-date"]), - ]; - assert_eq!( - DateBasedCalendarEvent::try_from(tags).unwrap_err(), - Error::InvalidDateFormat("not-a-date".to_string()) - ); - } - - #[test] - fn test_date_based_invalid_start_wrong_separator() { - let tags = vec![ - Tag::identifier("test"), - Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), - Tag::custom(TagKind::Start, ["2024/01/15"]), - ]; - assert_eq!( - DateBasedCalendarEvent::try_from(tags).unwrap_err(), - Error::InvalidDateFormat("2024/01/15".to_string()) - ); + for bad in ["not-a-date", "2024/01/15"] { + let tags = vec![ + Tag::identifier("test"), + Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), + Tag::custom(TagKind::Start, [bad]), + ]; + assert_eq!( + DateBasedCalendarEvent::try_from(tags).unwrap_err(), + Error::InvalidDateFormat(bad.to_string()) + ); + } } #[test] @@ -1402,34 +1343,6 @@ mod tests { ); } - #[test] - fn test_date_based_valid_date_format() { - let tags = vec![ - Tag::identifier("test"), - Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), - Tag::custom(TagKind::Start, ["2024-01-15"]), - Tag::custom(TagKind::End, ["2024-01-16"]), - ]; - let event = DateBasedCalendarEvent::try_from(tags).unwrap(); - assert_eq!(event.start, "2024-01-15"); - assert_eq!(event.end, Some("2024-01-16".to_string())); - } - - // M4: Invalid timestamp should return InvalidTimestamp, not StartMissing - - #[test] - fn test_time_based_invalid_start_timestamp() { - let tags = vec![ - Tag::identifier("test"), - Tag::from_standardized_without_cell(TagStandard::Title("Test".to_string())), - Tag::custom(TagKind::Start, ["not-a-timestamp"]), - ]; - assert_eq!( - TimeBasedCalendarEvent::try_from(tags).unwrap_err(), - Error::InvalidTimestamp("not-a-timestamp".to_string()) - ); - } - #[test] fn test_time_based_invalid_end_timestamp() { let tags = vec![ @@ -1562,28 +1475,34 @@ mod tests { let tags: Vec = calendar.clone().into(); let parsed = Calendar::try_from(tags).unwrap(); assert_eq!(parsed, calendar); - assert_eq!(parsed.coordinates.len(), 3); } #[test] - fn test_rsvp_unknown_status_returns_error() { + fn test_date_based_with_relay_url_and_image() { + let relay = RelayUrl::parse("wss://relay.example.com").unwrap(); + let image_url = Url::parse("https://example.com/img.png").unwrap(); let coord = Coordinate { - kind: Kind::TimeBasedCalendarEvent, + kind: Kind::from(31924), public_key: test_pubkey(), - identifier: "event".to_string(), + identifier: "my-cal".to_string(), }; - let tags = vec![ - Tag::identifier("test"), - Tag::from_standardized_without_cell(TagStandard::Coordinate { - coordinate: coord, - relay_url: None, - uppercase: false, - }), - Tag::custom(TagKind::Status, ["maybe"]), - ]; - assert_eq!( - CalendarEventRsvp::try_from(tags).unwrap_err(), - Error::UnknownRsvpStatus("maybe".to_string()) - ); + + let mut event = DateBasedCalendarEvent::new("test", "Test", "2024-06-01"); + event.image = Some((image_url, Some(ImageDimensions { width: 800, height: 600 }))); + event.coordinates = vec![(coord, Some(relay))]; + + let tags: Vec = event.clone().into(); + let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); + assert_eq!(parsed, event); + } + + #[test] + fn test_date_based_multiple_locations() { + let mut event = DateBasedCalendarEvent::new("fest", "Festival", "2024-07-04"); + event.locations = vec!["Main Stage".to_string(), "Side Tent".to_string()]; + + let tags: Vec = event.clone().into(); + let parsed = DateBasedCalendarEvent::try_from(tags).unwrap(); + assert_eq!(parsed, event); } } From 60edb4c58ed53959e254eb857d0692485da7c206 Mon Sep 17 00:00:00 2001 From: Oscar Bailey <79094698+ozgb@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:01:34 +0000 Subject: [PATCH 8/8] nip52: cargo fmt --- crates/nostr/src/nips/nip52.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/nostr/src/nips/nip52.rs b/crates/nostr/src/nips/nip52.rs index dd08e6bb8..8ef4676c4 100644 --- a/crates/nostr/src/nips/nip52.rs +++ b/crates/nostr/src/nips/nip52.rs @@ -1488,7 +1488,13 @@ mod tests { }; let mut event = DateBasedCalendarEvent::new("test", "Test", "2024-06-01"); - event.image = Some((image_url, Some(ImageDimensions { width: 800, height: 600 }))); + event.image = Some(( + image_url, + Some(ImageDimensions { + width: 800, + height: 600, + }), + )); event.coordinates = vec![(coord, Some(relay))]; let tags: Vec = event.clone().into();