Skip to main content

nula_core/nips/
nip52.rs

1//! [NIP-52] Calendar Events.
2//!
3//! Four addressable kinds:
4//!
5//! - **`kind: 31922` Date-based calendar event** — all-day /
6//!   multi-day events. `start` is an ISO-8601 `YYYY-MM-DD` and must
7//!   precede the optional `end`.
8//! - **`kind: 31923` Time-based calendar event** — spans between
9//!   Unix-seconds timestamps, optionally timezone-qualified via
10//!   `start_tzid` / `end_tzid`. `D` day-granularity floor timestamps
11//!   may repeat for multi-day ranges.
12//! - **`kind: 31924` Calendar** — an addressable list of calendar
13//!   events (`a` tags pointing at `31922` or `31923` events).
14//! - **`kind: 31925` Calendar event RSVP** — response to a specific
15//!   calendar event; carries `status` (`accepted`/`declined`/
16//!   `tentative`) plus optional `fb` free/busy hint.
17//!
18//! Common tags shared by both event kinds (`title`, `summary`,
19//! `image`, `location` repeated, `g` geohash, `p` participants with
20//! role, `t` hashtags, `r` references, and `a` collaborative
21//! requests) are modelled uniformly on [`CalendarEventCommon`]. Each
22//! kind-specific bundle composes that common struct with its own
23//! required columns.
24//!
25//! # Dates and timestamps
26//!
27//! - Date-based events use [`CalendarDate`], a newtype over `String`
28//!   that validates the `YYYY-MM-DD` shape without pulling in a full
29//!   calendar library. Parsing verifies numeric ranges (month
30//!   `1..=12`, day `1..=31`) but does not enforce real-month bounds
31//!   (e.g. April 31) to stay permissive for malformed producers.
32//! - Time-based events reuse [`Timestamp`] for `start`/`end` and
33//!   keep `start_tzid` / `end_tzid` as opaque `String`s (IANA zone
34//!   identifiers).
35//!
36//! [NIP-52]: https://github.com/nostr-protocol/nips/blob/master/52.md
37
38use std::{fmt, num::ParseIntError, str::FromStr};
39
40use thiserror::Error;
41
42use crate::event::{
43    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
44    SingleLetterTag, Tag, TagKind, Tags,
45};
46use crate::key::{PublicKey, PublicKeyError};
47use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError};
48
49/// `kind: 31922` — date-based calendar event.
50pub const KIND_DATE_EVENT: Kind = Kind::CALENDAR_DATE_EVENT;
51
52/// `kind: 31923` — time-based calendar event.
53pub const KIND_TIME_EVENT: Kind = Kind::CALENDAR_TIME_EVENT;
54
55/// `kind: 31924` — calendar.
56pub const KIND_CALENDAR: Kind = Kind::CALENDAR;
57
58/// `kind: 31925` — calendar event RSVP.
59pub const KIND_RSVP: Kind = Kind::CALENDAR_RSVP;
60
61const TITLE_TAG: &str = "title";
62const SUMMARY_TAG: &str = "summary";
63const IMAGE_TAG: &str = "image";
64const LOCATION_TAG: &str = "location";
65const START_TAG: &str = "start";
66const END_TAG: &str = "end";
67const START_TZID_TAG: &str = "start_tzid";
68const END_TZID_TAG: &str = "end_tzid";
69const STATUS_TAG: &str = "status";
70const FREE_BUSY_TAG: &str = "fb";
71const SECONDS_PER_DAY: i64 = 86_400;
72
73/// `YYYY-MM-DD` date string used by date-based calendar events.
74///
75/// The constructor validates the shape (10 chars, `-` separators,
76/// numeric month/day ranges) without pulling in a full calendar
77/// crate — producers stay permissive enough to round-trip malformed
78/// spec-adjacent content.
79#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
80pub struct CalendarDate(String);
81
82impl CalendarDate {
83    /// Parse a `YYYY-MM-DD` string.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`CalendarDateError`] when the input does not match
88    /// the spec-required shape or has out-of-range columns.
89    pub fn parse(input: &str) -> Result<Self, CalendarDateError> {
90        if input.len() != 10 {
91            return Err(CalendarDateError::WrongLength);
92        }
93        let bytes = input.as_bytes();
94        if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
95            return Err(CalendarDateError::MissingSeparator);
96        }
97        let year: u16 = input
98            .get(0..4)
99            .ok_or(CalendarDateError::WrongLength)?
100            .parse()?;
101        let month: u8 = input
102            .get(5..7)
103            .ok_or(CalendarDateError::WrongLength)?
104            .parse()?;
105        let day: u8 = input
106            .get(8..10)
107            .ok_or(CalendarDateError::WrongLength)?
108            .parse()?;
109        if !(1..=12).contains(&month) {
110            return Err(CalendarDateError::InvalidMonth(month));
111        }
112        if !(1..=31).contains(&day) {
113            return Err(CalendarDateError::InvalidDay(day));
114        }
115        // Canonical form: zero-pad components so comparisons sort
116        // chronologically without date-library dependencies.
117        Ok(Self(format!("{year:04}-{month:02}-{day:02}")))
118    }
119
120    /// View as the canonical `YYYY-MM-DD` string.
121    #[must_use]
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125
126    /// Consume and yield the canonical string.
127    #[must_use]
128    pub fn into_string(self) -> String {
129        self.0
130    }
131}
132
133impl fmt::Display for CalendarDate {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(&self.0)
136    }
137}
138
139impl FromStr for CalendarDate {
140    type Err = CalendarDateError;
141
142    fn from_str(input: &str) -> Result<Self, Self::Err> {
143        Self::parse(input)
144    }
145}
146
147/// Errors raised by [`CalendarDate::parse`].
148#[derive(Debug, Error)]
149pub enum CalendarDateError {
150    /// Input did not contain exactly 10 characters.
151    #[error("calendar date must be 10 characters long (`YYYY-MM-DD`)")]
152    WrongLength,
153    /// `-` separators missing at the expected positions.
154    #[error("calendar date must use `-` separators at positions 4 and 7")]
155    MissingSeparator,
156    /// Month column outside the `1..=12` range.
157    #[error("calendar date month out of range: {0}")]
158    InvalidMonth(u8),
159    /// Day column outside the `1..=31` range.
160    #[error("calendar date day out of range: {0}")]
161    InvalidDay(u8),
162    /// Year / month / day column failed to parse.
163    #[error(transparent)]
164    InvalidNumber(#[from] ParseIntError),
165}
166
167/// A `p` participant tag on a calendar event.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Participant {
170    /// Participant pubkey.
171    pub pubkey: PublicKey,
172    /// Optional relay hint column.
173    pub relay_hint: Option<RelayUrl>,
174    /// Optional display role (`Host`, `Speaker`, …). Free-form per
175    /// spec.
176    pub role: Option<String>,
177}
178
179impl Participant {
180    /// Construct a participant with no relay hint or role.
181    #[must_use]
182    pub const fn new(pubkey: PublicKey) -> Self {
183        Self {
184            pubkey,
185            relay_hint: None,
186            role: None,
187        }
188    }
189
190    /// Attach a relay hint.
191    #[must_use]
192    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
193        self.relay_hint = Some(relay);
194        self
195    }
196
197    /// Attach a display role.
198    #[must_use]
199    pub fn role(mut self, role: impl Into<String>) -> Self {
200        self.role = Some(role.into());
201        self
202    }
203
204    /// Render as a `p` tag.
205    #[must_use]
206    pub fn to_tag(&self) -> Tag {
207        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
208        let relay = self
209            .relay_hint
210            .as_ref()
211            .map_or_else(String::new, |r| r.as_str().to_owned());
212        if let Some(role) = &self.role {
213            Tag::with(&head, [self.pubkey.to_hex(), relay, role.clone()])
214        } else if self.relay_hint.is_some() {
215            Tag::with(&head, [self.pubkey.to_hex(), relay])
216        } else {
217            Tag::with(&head, [self.pubkey.to_hex()])
218        }
219    }
220
221    /// Parse a `p` tag into a [`Participant`].
222    ///
223    /// # Errors
224    ///
225    /// - [`CalendarError::MalformedParticipant`] when column 1 is
226    ///   absent.
227    /// - Wrapped [`PublicKeyError`] / [`RelayUrlError`] for invalid
228    ///   values.
229    pub fn from_tag(tag: &Tag) -> Result<Self, CalendarError> {
230        let pk_hex = tag.get(1).ok_or(CalendarError::MalformedParticipant)?;
231        let pubkey = PublicKey::parse(pk_hex)?;
232        let relay_hint = match tag.get(2) {
233            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
234            _ => None,
235        };
236        let role = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
237        Ok(Self {
238            pubkey,
239            relay_hint,
240            role,
241        })
242    }
243}
244
245/// An `a` tag requesting inclusion in a calendar (spec
246/// §"Collaborative Calendar Event Requests").
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct CalendarRequest {
249    /// Target calendar coordinate.
250    pub calendar: Coordinate,
251    /// Optional relay hint.
252    pub relay_hint: Option<RelayUrl>,
253}
254
255/// Fields shared by both date-based and time-based calendar events.
256#[derive(Debug, Clone, PartialEq, Eq, Default)]
257pub struct CalendarEventCommon {
258    /// Markdown / plain-text description (`.content`).
259    pub content: String,
260    /// Required `d` identifier.
261    pub identifier: String,
262    /// Required `title` (per spec).
263    pub title: String,
264    /// Optional `summary`.
265    pub summary: Option<String>,
266    /// Optional `image` URL.
267    pub image: Option<Url>,
268    /// `location` tags (repeated).
269    pub locations: Vec<String>,
270    /// `g` geohash.
271    pub geohash: Option<String>,
272    /// `p` participants.
273    pub participants: Vec<Participant>,
274    /// `t` hashtags (lower-cased).
275    pub hashtags: Vec<String>,
276    /// `r` references.
277    pub references: Vec<Url>,
278    /// Collaborative-request `a` tags pointing at parent calendars.
279    pub calendar_requests: Vec<CalendarRequest>,
280    /// Forward-compatible passthrough for unknown tags.
281    pub extra_tags: Vec<Tag>,
282}
283
284/// Typed bundle for a `kind: 31922` date-based calendar event.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct DateCalendarEvent {
287    /// Fields common to every calendar event.
288    pub common: CalendarEventCommon,
289    /// Inclusive start date (required).
290    pub start: CalendarDate,
291    /// Exclusive end date (optional). If absent, the event ends on
292    /// the same day as `start`.
293    pub end: Option<CalendarDate>,
294}
295
296/// Typed bundle for a `kind: 31923` time-based calendar event.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct TimeCalendarEvent {
299    /// Fields common to every calendar event.
300    pub common: CalendarEventCommon,
301    /// Inclusive start Unix timestamp.
302    pub start: Timestamp,
303    /// Exclusive end Unix timestamp. If absent, the event ends
304    /// instantaneously (spec §"Time-Based Calendar Event").
305    pub end: Option<Timestamp>,
306    /// Optional `start_tzid` IANA time-zone identifier.
307    pub start_tzid: Option<String>,
308    /// Optional `end_tzid` IANA time-zone identifier.
309    pub end_tzid: Option<String>,
310}
311
312impl TimeCalendarEvent {
313    /// Compute the spec-required `D` day-granularity floor
314    /// timestamps spanning `start`..=`end`.
315    ///
316    /// The spec §"Time-Based Calendar Event" requires
317    /// `D = floor(unix_seconds() / seconds_in_one_day)` and multiple
318    /// tags to span the range. If `end` is `None`, a single `D` row
319    /// is emitted for `start`.
320    #[must_use]
321    pub fn day_floors(&self) -> Vec<i64> {
322        let start_day = i64::try_from(self.start.as_secs())
323            .unwrap_or(i64::MAX)
324            .div_euclid(SECONDS_PER_DAY);
325        let end_day = self.end.map_or(start_day, |e| {
326            i64::try_from(e.as_secs())
327                .unwrap_or(i64::MAX)
328                .div_euclid(SECONDS_PER_DAY)
329        });
330        if end_day < start_day {
331            return vec![start_day];
332        }
333        (start_day..=end_day).collect()
334    }
335}
336
337/// `kind: 31924` calendar bundle.
338#[derive(Debug, Clone, PartialEq, Eq, Default)]
339pub struct Calendar {
340    /// `d` identifier.
341    pub identifier: String,
342    /// Required `title`.
343    pub title: String,
344    /// `.content` — calendar description.
345    pub content: String,
346    /// Event references (`a` tags).
347    pub events: Vec<CalendarRequest>,
348    /// Forward-compatible passthrough for unknown tags.
349    pub extra_tags: Vec<Tag>,
350}
351
352/// Spec-defined wire tokens for the RSVP `status` tag.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
354pub enum RsvpStatus {
355    /// `accepted`.
356    Accepted,
357    /// `declined`.
358    Declined,
359    /// `tentative`.
360    Tentative,
361}
362
363impl RsvpStatus {
364    /// Wire token.
365    #[must_use]
366    pub const fn as_str(self) -> &'static str {
367        match self {
368            Self::Accepted => "accepted",
369            Self::Declined => "declined",
370            Self::Tentative => "tentative",
371        }
372    }
373
374    /// Parse a wire token.
375    #[must_use]
376    pub fn parse(token: &str) -> Option<Self> {
377        match token {
378            "accepted" => Some(Self::Accepted),
379            "declined" => Some(Self::Declined),
380            "tentative" => Some(Self::Tentative),
381            _ => None,
382        }
383    }
384}
385
386/// Spec-defined wire tokens for the RSVP `fb` (free/busy) tag.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
388pub enum FreeBusy {
389    /// `free`.
390    Free,
391    /// `busy`.
392    Busy,
393}
394
395impl FreeBusy {
396    /// Wire token.
397    #[must_use]
398    pub const fn as_str(self) -> &'static str {
399        match self {
400            Self::Free => "free",
401            Self::Busy => "busy",
402        }
403    }
404
405    /// Parse a wire token.
406    #[must_use]
407    pub fn parse(token: &str) -> Option<Self> {
408        match token {
409            "free" => Some(Self::Free),
410            "busy" => Some(Self::Busy),
411            _ => None,
412        }
413    }
414}
415
416/// `kind: 31925` RSVP bundle.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct Rsvp {
419    /// `d` identifier (unique per RSVP).
420    pub identifier: String,
421    /// Referenced calendar-event coordinate (`a` tag — required).
422    pub event_coordinate: Coordinate,
423    /// Optional relay hint for the coordinate.
424    pub event_coordinate_relay_hint: Option<RelayUrl>,
425    /// Optional specific revision (`e` tag).
426    pub event_id: Option<EventId>,
427    /// Optional relay hint for the revision.
428    pub event_id_relay_hint: Option<RelayUrl>,
429    /// Required `status`.
430    pub status: RsvpStatus,
431    /// Optional `fb` free/busy hint (ignored when
432    /// `status == Declined` per spec).
433    pub free_busy: Option<FreeBusy>,
434    /// Optional `p` tag — author of the referenced event.
435    pub event_author: Option<PublicKey>,
436    /// Optional relay hint for the author.
437    pub event_author_relay_hint: Option<RelayUrl>,
438    /// `.content` — free-form note.
439    pub content: String,
440    /// Forward-compatible passthrough for unknown tags.
441    pub extra_tags: Vec<Tag>,
442}
443
444/// Errors raised by NIP-52 parsers.
445#[derive(Debug, Error)]
446#[non_exhaustive]
447pub enum CalendarError {
448    /// Unexpected event kind.
449    #[error("unexpected kind for NIP-52 event: {}", .0.as_u16())]
450    WrongKind(Kind),
451    /// `d` tag is absent.
452    #[error("NIP-52 event missing `d` tag")]
453    MissingIdentifier,
454    /// `title` tag is absent.
455    #[error("NIP-52 event missing `title` tag")]
456    MissingTitle,
457    /// `start` tag is absent.
458    #[error("NIP-52 event missing `start` tag")]
459    MissingStart,
460    /// RSVP `a` tag is absent.
461    #[error("NIP-52 RSVP missing calendar-event coordinate")]
462    MissingRsvpCoordinate,
463    /// RSVP `status` tag is absent.
464    #[error("NIP-52 RSVP missing `status` tag")]
465    MissingRsvpStatus,
466    /// `status` token is not one of the spec-defined values.
467    #[error("invalid RSVP status: `{0}`")]
468    InvalidRsvpStatus(String),
469    /// `fb` token is not `free` or `busy`.
470    #[error("invalid free/busy value: `{0}`")]
471    InvalidFreeBusy(String),
472    /// `p` tag is missing the pubkey column.
473    #[error("`p` participant tag missing pubkey")]
474    MalformedParticipant,
475    /// `a` tag is missing the coordinate column.
476    #[error("`a` tag missing coordinate")]
477    MalformedAddress,
478    /// `image` tag is missing the URL column.
479    #[error("`image` tag missing URL")]
480    MalformedImage,
481    /// Wrapped calendar-date parser error.
482    #[error(transparent)]
483    InvalidCalendarDate(#[from] CalendarDateError),
484    /// Wrapped timestamp parser error.
485    #[error(transparent)]
486    InvalidTimestamp(#[from] TimestampError),
487    /// Wrapped pubkey parser error.
488    #[error(transparent)]
489    InvalidPublicKey(#[from] PublicKeyError),
490    /// Wrapped coordinate parser error.
491    #[error(transparent)]
492    InvalidCoordinate(#[from] CoordinateError),
493    /// Wrapped event-id parser error.
494    #[error(transparent)]
495    InvalidEventId(#[from] EventIdError),
496    /// Wrapped URL parser error.
497    #[error(transparent)]
498    InvalidUrl(#[from] UrlError),
499    /// Wrapped relay-URL parser error.
500    #[error(transparent)]
501    InvalidRelayUrl(#[from] RelayUrlError),
502}
503
504impl DateCalendarEvent {
505    /// Construct a date-based calendar event with the spec-required
506    /// columns.
507    #[must_use]
508    pub fn new(
509        identifier: impl Into<String>,
510        title: impl Into<String>,
511        start: CalendarDate,
512    ) -> Self {
513        Self {
514            common: CalendarEventCommon {
515                identifier: identifier.into(),
516                title: title.into(),
517                ..CalendarEventCommon::default()
518            },
519            start,
520            end: None,
521        }
522    }
523
524    /// Set the exclusive end date.
525    #[must_use]
526    pub fn end(mut self, end: CalendarDate) -> Self {
527        self.end = Some(end);
528        self
529    }
530
531    /// Build the event's addressable coordinate.
532    #[must_use]
533    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
534        Coordinate::new(KIND_DATE_EVENT, author, self.common.identifier.clone())
535    }
536
537    /// Parse a `kind: 31922` event into a typed bundle.
538    ///
539    /// # Errors
540    ///
541    /// See [`CalendarError`] for the failure modes.
542    pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
543        if event.kind != KIND_DATE_EVENT {
544            return Err(CalendarError::WrongKind(event.kind));
545        }
546        let (common, mut state) = parse_common(event)?;
547        let start = state.start_date.take().ok_or(CalendarError::MissingStart)?;
548        let end = state.end_date.take();
549        Ok(Self { common, start, end })
550    }
551}
552
553impl TimeCalendarEvent {
554    /// Construct a time-based calendar event with the spec-required
555    /// columns.
556    #[must_use]
557    pub fn new(identifier: impl Into<String>, title: impl Into<String>, start: Timestamp) -> Self {
558        Self {
559            common: CalendarEventCommon {
560                identifier: identifier.into(),
561                title: title.into(),
562                ..CalendarEventCommon::default()
563            },
564            start,
565            end: None,
566            start_tzid: None,
567            end_tzid: None,
568        }
569    }
570
571    /// Set the exclusive end timestamp.
572    #[must_use]
573    pub const fn end(mut self, end: Timestamp) -> Self {
574        self.end = Some(end);
575        self
576    }
577
578    /// Set the `start_tzid` IANA identifier.
579    #[must_use]
580    pub fn start_tzid(mut self, tzid: impl Into<String>) -> Self {
581        self.start_tzid = Some(tzid.into());
582        self
583    }
584
585    /// Set the `end_tzid` IANA identifier.
586    #[must_use]
587    pub fn end_tzid(mut self, tzid: impl Into<String>) -> Self {
588        self.end_tzid = Some(tzid.into());
589        self
590    }
591
592    /// Build the event's addressable coordinate.
593    #[must_use]
594    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
595        Coordinate::new(KIND_TIME_EVENT, author, self.common.identifier.clone())
596    }
597
598    /// Parse a `kind: 31923` event into a typed bundle.
599    ///
600    /// # Errors
601    ///
602    /// See [`CalendarError`] for the failure modes.
603    pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
604        if event.kind != KIND_TIME_EVENT {
605            return Err(CalendarError::WrongKind(event.kind));
606        }
607        let (common, mut state) = parse_common(event)?;
608        let start = state.start_ts.take().ok_or(CalendarError::MissingStart)?;
609        let end = state.end_ts.take();
610        let start_tzid = state.start_tzid.take();
611        let end_tzid = state.end_tzid.take();
612        Ok(Self {
613            common,
614            start,
615            end,
616            start_tzid,
617            end_tzid,
618        })
619    }
620}
621
622impl Calendar {
623    /// Construct a calendar with the spec-required columns.
624    #[must_use]
625    pub fn new(identifier: impl Into<String>, title: impl Into<String>) -> Self {
626        Self {
627            identifier: identifier.into(),
628            title: title.into(),
629            content: String::new(),
630            events: Vec::new(),
631            extra_tags: Vec::new(),
632        }
633    }
634
635    /// Set the description body.
636    #[must_use]
637    pub fn content(mut self, content: impl Into<String>) -> Self {
638        self.content = content.into();
639        self
640    }
641
642    /// Append a referenced event.
643    #[must_use]
644    pub fn event(mut self, request: CalendarRequest) -> Self {
645        self.events.push(request);
646        self
647    }
648
649    /// Build the calendar's addressable coordinate.
650    #[must_use]
651    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
652        Coordinate::new(KIND_CALENDAR, author, self.identifier.clone())
653    }
654
655    /// Parse a `kind: 31924` event into a typed bundle.
656    ///
657    /// # Errors
658    ///
659    /// See [`CalendarError`] for the failure modes.
660    pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
661        if event.kind != KIND_CALENDAR {
662            return Err(CalendarError::WrongKind(event.kind));
663        }
664        let identifier = d_value(&event.tags)
665            .ok_or(CalendarError::MissingIdentifier)?
666            .to_owned();
667        let mut out = Self {
668            identifier,
669            content: event.content.clone(),
670            ..Self::default()
671        };
672        for tag in &event.tags {
673            match tag.kind() {
674                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
675                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
676                    out.events.push(parse_calendar_request(tag)?);
677                }
678                _ if tag.name() == TITLE_TAG => {
679                    out.title = tag.get(1).map(str::to_owned).unwrap_or_default();
680                }
681                _ => out.extra_tags.push(tag.clone()),
682            }
683        }
684        if out.title.is_empty() {
685            return Err(CalendarError::MissingTitle);
686        }
687        Ok(out)
688    }
689}
690
691impl Rsvp {
692    /// Construct an RSVP with the spec-required columns.
693    #[must_use]
694    pub fn new(
695        identifier: impl Into<String>,
696        event_coordinate: Coordinate,
697        status: RsvpStatus,
698    ) -> Self {
699        Self {
700            identifier: identifier.into(),
701            event_coordinate,
702            event_coordinate_relay_hint: None,
703            event_id: None,
704            event_id_relay_hint: None,
705            status,
706            free_busy: None,
707            event_author: None,
708            event_author_relay_hint: None,
709            content: String::new(),
710            extra_tags: Vec::new(),
711        }
712    }
713
714    /// Attach a free/busy hint.
715    ///
716    /// Per spec §"Calendar Event RSVP", the `fb` tag is MUST be
717    /// omitted when `status == Declined`; this builder doesn't
718    /// enforce that — callers should respect the invariant.
719    #[must_use]
720    pub const fn free_busy(mut self, fb: FreeBusy) -> Self {
721        self.free_busy = Some(fb);
722        self
723    }
724
725    /// Attach the referenced event's author pubkey.
726    #[must_use]
727    pub const fn event_author(mut self, pubkey: PublicKey) -> Self {
728        self.event_author = Some(pubkey);
729        self
730    }
731
732    /// Attach a specific event-id revision.
733    #[must_use]
734    pub const fn event_id(mut self, id: EventId) -> Self {
735        self.event_id = Some(id);
736        self
737    }
738
739    /// Set the free-form note.
740    #[must_use]
741    pub fn content(mut self, content: impl Into<String>) -> Self {
742        self.content = content.into();
743        self
744    }
745
746    /// Build the RSVP's addressable coordinate.
747    #[must_use]
748    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
749        Coordinate::new(KIND_RSVP, author, self.identifier.clone())
750    }
751
752    /// Parse a `kind: 31925` event into a typed bundle.
753    ///
754    /// # Errors
755    ///
756    /// See [`CalendarError`] for the failure modes.
757    pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
758        if event.kind != KIND_RSVP {
759            return Err(CalendarError::WrongKind(event.kind));
760        }
761        let identifier = d_value(&event.tags)
762            .ok_or(CalendarError::MissingIdentifier)?
763            .to_owned();
764        let mut coord: Option<(Coordinate, Option<RelayUrl>)> = None;
765        let mut evid: Option<(EventId, Option<RelayUrl>)> = None;
766        let mut status: Option<RsvpStatus> = None;
767        let mut free_busy: Option<FreeBusy> = None;
768        let mut author: Option<(PublicKey, Option<RelayUrl>)> = None;
769        let mut extra_tags: Vec<Tag> = Vec::new();
770        for tag in &event.tags {
771            match tag.kind() {
772                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
773                TagKind::SingleLetter(s)
774                    if !s.uppercase && s.character == Alphabet::A && coord.is_none() =>
775                {
776                    let req = parse_calendar_request(tag)?;
777                    coord = Some((req.calendar, req.relay_hint));
778                }
779                TagKind::SingleLetter(s)
780                    if !s.uppercase && s.character == Alphabet::E && evid.is_none() =>
781                {
782                    evid = Some(parse_event_ref(tag)?);
783                }
784                TagKind::SingleLetter(s)
785                    if !s.uppercase && s.character == Alphabet::P && author.is_none() =>
786                {
787                    author = Some(parse_pubkey_ref(tag)?);
788                }
789                _ if tag.name() == STATUS_TAG => {
790                    let raw = tag.get(1).ok_or(CalendarError::MissingRsvpStatus)?;
791                    status = Some(
792                        RsvpStatus::parse(raw)
793                            .ok_or_else(|| CalendarError::InvalidRsvpStatus(raw.to_owned()))?,
794                    );
795                }
796                _ if tag.name() == FREE_BUSY_TAG => free_busy = parse_free_busy_tag(tag)?,
797                _ => extra_tags.push(tag.clone()),
798            }
799        }
800        let (event_coordinate, event_coordinate_relay_hint) =
801            coord.ok_or(CalendarError::MissingRsvpCoordinate)?;
802        let status = status.ok_or(CalendarError::MissingRsvpStatus)?;
803        let (event_id, event_id_relay_hint) =
804            evid.map_or((None, None), |(id, relay)| (Some(id), relay));
805        let (event_author, event_author_relay_hint) =
806            author.map_or((None, None), |(pk, relay)| (Some(pk), relay));
807        Ok(Self {
808            identifier,
809            event_coordinate,
810            event_coordinate_relay_hint,
811            event_id,
812            event_id_relay_hint,
813            status,
814            free_busy,
815            event_author,
816            event_author_relay_hint,
817            content: event.content.clone(),
818            extra_tags,
819        })
820    }
821}
822
823#[derive(Default)]
824struct CommonParseState {
825    start_date: Option<CalendarDate>,
826    end_date: Option<CalendarDate>,
827    start_ts: Option<Timestamp>,
828    end_ts: Option<Timestamp>,
829    start_tzid: Option<String>,
830    end_tzid: Option<String>,
831}
832
833fn parse_common(event: &Event) -> Result<(CalendarEventCommon, CommonParseState), CalendarError> {
834    let identifier = d_value(&event.tags)
835        .ok_or(CalendarError::MissingIdentifier)?
836        .to_owned();
837    let mut common = CalendarEventCommon {
838        identifier,
839        content: event.content.clone(),
840        ..CalendarEventCommon::default()
841    };
842    let mut state = CommonParseState::default();
843    for tag in &event.tags {
844        if absorb_common_single_letter(tag, &mut common)? {
845            continue;
846        }
847        absorb_common_named_tag(tag, event.kind, &mut common, &mut state)?;
848    }
849    if common.title.is_empty() {
850        return Err(CalendarError::MissingTitle);
851    }
852    Ok((common, state))
853}
854
855fn absorb_common_single_letter(
856    tag: &Tag,
857    common: &mut CalendarEventCommon,
858) -> Result<bool, CalendarError> {
859    let TagKind::SingleLetter(s) = tag.kind() else {
860        return Ok(false);
861    };
862    if s.uppercase {
863        return Ok(false);
864    }
865    match s.character {
866        Alphabet::D => Ok(true),
867        Alphabet::G => {
868            common.geohash = tag.get(1).map(str::to_owned);
869            Ok(true)
870        }
871        Alphabet::P => {
872            common.participants.push(Participant::from_tag(tag)?);
873            Ok(true)
874        }
875        Alphabet::T => {
876            if let Some(raw) = tag.get(1) {
877                common.hashtags.push(raw.to_ascii_lowercase());
878            }
879            Ok(true)
880        }
881        Alphabet::R => {
882            if let Some(raw) = tag.get(1) {
883                common.references.push(Url::parse(raw)?);
884            }
885            Ok(true)
886        }
887        Alphabet::A => {
888            common.calendar_requests.push(parse_calendar_request(tag)?);
889            Ok(true)
890        }
891        _ => Ok(false),
892    }
893}
894
895fn absorb_common_named_tag(
896    tag: &Tag,
897    kind: Kind,
898    common: &mut CalendarEventCommon,
899    state: &mut CommonParseState,
900) -> Result<(), CalendarError> {
901    match tag.name() {
902        TITLE_TAG => {
903            common.title = tag.get(1).map(str::to_owned).unwrap_or_default();
904        }
905        SUMMARY_TAG => common.summary = tag.get(1).map(str::to_owned),
906        IMAGE_TAG => {
907            let raw = tag.get(1).ok_or(CalendarError::MalformedImage)?;
908            common.image = Some(Url::parse(raw)?);
909        }
910        LOCATION_TAG => {
911            if let Some(raw) = tag.get(1) {
912                common.locations.push(raw.to_owned());
913            }
914        }
915        START_TAG => parse_start_tag(kind, tag, state)?,
916        END_TAG => parse_end_tag(kind, tag, state)?,
917        START_TZID_TAG => state.start_tzid = tag.get(1).map(str::to_owned),
918        END_TZID_TAG => state.end_tzid = tag.get(1).map(str::to_owned),
919        // Drop the `D` day-floor tags — they're derivable from
920        // `start`/`end` and should not round-trip independently.
921        "D" => {}
922        _ => common.extra_tags.push(tag.clone()),
923    }
924    Ok(())
925}
926
927fn parse_free_busy_tag(tag: &Tag) -> Result<Option<FreeBusy>, CalendarError> {
928    let Some(raw) = tag.get(1) else {
929        return Ok(None);
930    };
931    let fb = FreeBusy::parse(raw).ok_or_else(|| CalendarError::InvalidFreeBusy(raw.to_owned()))?;
932    Ok(Some(fb))
933}
934
935fn parse_start_tag(
936    kind: Kind,
937    tag: &Tag,
938    state: &mut CommonParseState,
939) -> Result<(), CalendarError> {
940    let Some(raw) = tag.get(1) else {
941        return Ok(());
942    };
943    if kind == KIND_DATE_EVENT {
944        state.start_date = Some(CalendarDate::parse(raw)?);
945    } else {
946        state.start_ts = Some(raw.parse::<Timestamp>()?);
947    }
948    Ok(())
949}
950
951fn parse_end_tag(kind: Kind, tag: &Tag, state: &mut CommonParseState) -> Result<(), CalendarError> {
952    let Some(raw) = tag.get(1) else {
953        return Ok(());
954    };
955    if kind == KIND_DATE_EVENT {
956        state.end_date = Some(CalendarDate::parse(raw)?);
957    } else {
958        state.end_ts = Some(raw.parse::<Timestamp>()?);
959    }
960    Ok(())
961}
962
963fn parse_calendar_request(tag: &Tag) -> Result<CalendarRequest, CalendarError> {
964    let coord_str = tag.get(1).ok_or(CalendarError::MalformedAddress)?;
965    let calendar = Coordinate::parse(coord_str)?;
966    let relay_hint = match tag.get(2) {
967        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
968        _ => None,
969    };
970    Ok(CalendarRequest {
971        calendar,
972        relay_hint,
973    })
974}
975
976fn parse_event_ref(tag: &Tag) -> Result<(EventId, Option<RelayUrl>), CalendarError> {
977    let id_hex = tag.get(1).ok_or(CalendarError::MalformedAddress)?;
978    let id = EventId::parse(id_hex)?;
979    let relay_hint = match tag.get(2) {
980        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
981        _ => None,
982    };
983    Ok((id, relay_hint))
984}
985
986fn parse_pubkey_ref(tag: &Tag) -> Result<(PublicKey, Option<RelayUrl>), CalendarError> {
987    let pk_hex = tag.get(1).ok_or(CalendarError::MalformedParticipant)?;
988    let pubkey = PublicKey::parse(pk_hex)?;
989    let relay_hint = match tag.get(2) {
990        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
991        _ => None,
992    };
993    Ok((pubkey, relay_hint))
994}
995
996fn d_value(tags: &Tags) -> Option<&str> {
997    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
998    tags.find_first(&head).and_then(|tag| tag.get(1))
999}
1000
1001fn apply_common(common: &CalendarEventCommon, mut builder: EventBuilder) -> EventBuilder {
1002    if let Some(summary) = &common.summary {
1003        builder = builder.tag(Tag::with(
1004            &TagKind::from_wire(SUMMARY_TAG),
1005            [summary.clone()],
1006        ));
1007    }
1008    if let Some(image) = &common.image {
1009        builder = builder.tag(Tag::with(
1010            &TagKind::from_wire(IMAGE_TAG),
1011            [image.as_str().to_owned()],
1012        ));
1013    }
1014    for location in &common.locations {
1015        builder = builder.tag(Tag::with(
1016            &TagKind::from_wire(LOCATION_TAG),
1017            [location.clone()],
1018        ));
1019    }
1020    if let Some(g) = &common.geohash {
1021        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G));
1022        builder = builder.tag(Tag::with(&head, [g.clone()]));
1023    }
1024    for participant in &common.participants {
1025        builder = builder.tag(participant.to_tag());
1026    }
1027    for hashtag in &common.hashtags {
1028        builder = builder.tag(Tag::t(hashtag));
1029    }
1030    for url in &common.references {
1031        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
1032        builder = builder.tag(Tag::with(&head, [url.as_str().to_owned()]));
1033    }
1034    for req in &common.calendar_requests {
1035        builder = builder.tag(calendar_request_tag(req));
1036    }
1037    for tag in &common.extra_tags {
1038        builder = builder.tag(tag.clone());
1039    }
1040    builder
1041}
1042
1043fn calendar_request_tag(req: &CalendarRequest) -> Tag {
1044    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
1045    req.relay_hint.as_ref().map_or_else(
1046        || Tag::with(&head, [req.calendar.to_wire()]),
1047        |relay| Tag::with(&head, [req.calendar.to_wire(), relay.as_str().to_owned()]),
1048    )
1049}
1050
1051impl EventBuilder {
1052    /// Author a NIP-52 `kind: 31922` date-based calendar event.
1053    #[must_use]
1054    pub fn calendar_date_event(event: &DateCalendarEvent) -> Self {
1055        let mut builder = Self::new(KIND_DATE_EVENT, event.common.content.clone());
1056        builder = builder
1057            .tag(Tag::d(&event.common.identifier))
1058            .tag(Tag::with(
1059                &TagKind::from_wire(TITLE_TAG),
1060                [event.common.title.clone()],
1061            ))
1062            .tag(Tag::with(
1063                &TagKind::from_wire(START_TAG),
1064                [event.start.as_str().to_owned()],
1065            ));
1066        if let Some(end) = &event.end {
1067            builder = builder.tag(Tag::with(
1068                &TagKind::from_wire(END_TAG),
1069                [end.as_str().to_owned()],
1070            ));
1071        }
1072        apply_common(&event.common, builder)
1073    }
1074
1075    /// Author a NIP-52 `kind: 31923` time-based calendar event.
1076    #[must_use]
1077    pub fn calendar_time_event(event: &TimeCalendarEvent) -> Self {
1078        let mut builder = Self::new(KIND_TIME_EVENT, event.common.content.clone());
1079        builder = builder
1080            .tag(Tag::d(&event.common.identifier))
1081            .tag(Tag::with(
1082                &TagKind::from_wire(TITLE_TAG),
1083                [event.common.title.clone()],
1084            ))
1085            .tag(Tag::with(
1086                &TagKind::from_wire(START_TAG),
1087                [event.start.as_secs().to_string()],
1088            ));
1089        if let Some(end) = event.end {
1090            builder = builder.tag(Tag::with(
1091                &TagKind::from_wire(END_TAG),
1092                [end.as_secs().to_string()],
1093            ));
1094        }
1095        for floor in event.day_floors() {
1096            builder = builder.tag(Tag::with(&TagKind::from_wire("D"), [floor.to_string()]));
1097        }
1098        if let Some(tzid) = &event.start_tzid {
1099            builder = builder.tag(Tag::with(
1100                &TagKind::from_wire(START_TZID_TAG),
1101                [tzid.clone()],
1102            ));
1103        }
1104        if let Some(tzid) = &event.end_tzid {
1105            builder = builder.tag(Tag::with(&TagKind::from_wire(END_TZID_TAG), [tzid.clone()]));
1106        }
1107        apply_common(&event.common, builder)
1108    }
1109
1110    /// Author a NIP-52 `kind: 31924` calendar event.
1111    #[must_use]
1112    pub fn calendar(calendar: &Calendar) -> Self {
1113        let mut builder = Self::new(KIND_CALENDAR, calendar.content.clone())
1114            .tag(Tag::d(&calendar.identifier))
1115            .tag(Tag::with(
1116                &TagKind::from_wire(TITLE_TAG),
1117                [calendar.title.clone()],
1118            ));
1119        for req in &calendar.events {
1120            builder = builder.tag(calendar_request_tag(req));
1121        }
1122        for tag in &calendar.extra_tags {
1123            builder = builder.tag(tag.clone());
1124        }
1125        builder
1126    }
1127
1128    /// Author a NIP-52 `kind: 31925` RSVP event.
1129    #[must_use]
1130    pub fn calendar_rsvp(rsvp: &Rsvp) -> Self {
1131        let mut builder = Self::new(KIND_RSVP, rsvp.content.clone());
1132        builder = builder
1133            .tag(Tag::d(&rsvp.identifier))
1134            .tag(calendar_request_tag(&CalendarRequest {
1135                calendar: rsvp.event_coordinate.clone(),
1136                relay_hint: rsvp.event_coordinate_relay_hint.clone(),
1137            }))
1138            .tag(Tag::with(
1139                &TagKind::from_wire(STATUS_TAG),
1140                [rsvp.status.as_str().to_owned()],
1141            ));
1142        if let Some(id) = rsvp.event_id {
1143            let head_e = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
1144            builder = builder.tag(rsvp.event_id_relay_hint.as_ref().map_or_else(
1145                || Tag::with(&head_e, [id.to_hex()]),
1146                |relay| Tag::with(&head_e, [id.to_hex(), relay.as_str().to_owned()]),
1147            ));
1148        }
1149        if let Some(fb) = rsvp.free_busy
1150            && rsvp.status != RsvpStatus::Declined
1151        {
1152            builder = builder.tag(Tag::with(
1153                &TagKind::from_wire(FREE_BUSY_TAG),
1154                [fb.as_str().to_owned()],
1155            ));
1156        }
1157        if let Some(pk) = rsvp.event_author {
1158            let head_p = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
1159            builder = builder.tag(rsvp.event_author_relay_hint.as_ref().map_or_else(
1160                || Tag::with(&head_p, [pk.to_hex()]),
1161                |relay| Tag::with(&head_p, [pk.to_hex(), relay.as_str().to_owned()]),
1162            ));
1163        }
1164        for tag in &rsvp.extra_tags {
1165            builder = builder.tag(tag.clone());
1166        }
1167        builder
1168    }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174    use crate::Keys;
1175
1176    fn keys() -> Keys {
1177        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
1178    }
1179
1180    #[test]
1181    fn calendar_date_parses() {
1182        let d = CalendarDate::parse("2025-01-09").unwrap();
1183        assert_eq!(d.as_str(), "2025-01-09");
1184        assert!(matches!(
1185            CalendarDate::parse("2025/01/09"),
1186            Err(CalendarDateError::MissingSeparator)
1187        ));
1188        assert!(matches!(
1189            CalendarDate::parse("2025-13-09"),
1190            Err(CalendarDateError::InvalidMonth(13))
1191        ));
1192    }
1193
1194    #[test]
1195    fn date_event_round_trip() {
1196        let event = DateCalendarEvent::new(
1197            "holiday",
1198            "Holiday",
1199            CalendarDate::parse("2025-12-24").unwrap(),
1200        )
1201        .end(CalendarDate::parse("2025-12-26").unwrap());
1202        let signed = EventBuilder::calendar_date_event(&event)
1203            .sign_with_keys(&keys())
1204            .unwrap();
1205        let parsed = DateCalendarEvent::from_event(&signed).unwrap();
1206        assert_eq!(parsed, event);
1207    }
1208
1209    #[test]
1210    fn time_event_round_trip_with_participants() {
1211        let start = Timestamp::from_secs(1_700_000_000);
1212        let end = Timestamp::from_secs(1_700_003_600);
1213        let participant = Participant::new(*keys().public_key())
1214            .relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
1215            .role("Speaker");
1216        let mut event = TimeCalendarEvent::new("meet", "Meet", start)
1217            .end(end)
1218            .start_tzid("America/Costa_Rica")
1219            .end_tzid("America/Costa_Rica");
1220        event.common.participants.push(participant);
1221        event.common.summary = Some("brief".into());
1222        event.common.hashtags.push("ethereum".into());
1223        event.common.locations.push("online".into());
1224        let signed = EventBuilder::calendar_time_event(&event)
1225            .sign_with_keys(&keys())
1226            .unwrap();
1227        let parsed = TimeCalendarEvent::from_event(&signed).unwrap();
1228        assert_eq!(parsed, event);
1229    }
1230
1231    #[test]
1232    fn time_event_day_floors_cover_range() {
1233        let start = Timestamp::from_secs(86_400 * 10);
1234        let end = Timestamp::from_secs(86_400 * 12);
1235        let event = TimeCalendarEvent::new("multi", "Multi", start).end(end);
1236        assert_eq!(event.day_floors(), vec![10, 11, 12]);
1237    }
1238
1239    #[test]
1240    fn calendar_round_trip() {
1241        let coord = Coordinate::new(KIND_TIME_EVENT, *keys().public_key(), "meet");
1242        let calendar =
1243            Calendar::new("cal-1", "Work")
1244                .content("description")
1245                .event(CalendarRequest {
1246                    calendar: coord,
1247                    relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
1248                });
1249        let signed = EventBuilder::calendar(&calendar)
1250            .sign_with_keys(&keys())
1251            .unwrap();
1252        let parsed = Calendar::from_event(&signed).unwrap();
1253        assert_eq!(parsed, calendar);
1254    }
1255
1256    #[test]
1257    fn rsvp_round_trip() {
1258        let coord = Coordinate::new(KIND_TIME_EVENT, *keys().public_key(), "meet");
1259        let rsvp = Rsvp::new("rsvp-1", coord, RsvpStatus::Accepted)
1260            .free_busy(FreeBusy::Busy)
1261            .event_author(*keys().public_key())
1262            .event_id(EventId::from_byte_array([0xcc; 32]))
1263            .content("see you");
1264        let signed = EventBuilder::calendar_rsvp(&rsvp)
1265            .sign_with_keys(&keys())
1266            .unwrap();
1267        let parsed = Rsvp::from_event(&signed).unwrap();
1268        assert_eq!(parsed, rsvp);
1269    }
1270
1271    #[test]
1272    fn declined_rsvp_omits_fb() {
1273        let coord = Coordinate::new(KIND_TIME_EVENT, *keys().public_key(), "meet");
1274        let rsvp = Rsvp::new("rsvp-2", coord, RsvpStatus::Declined).free_busy(FreeBusy::Free);
1275        let signed = EventBuilder::calendar_rsvp(&rsvp)
1276            .sign_with_keys(&keys())
1277            .unwrap();
1278        let parsed = Rsvp::from_event(&signed).unwrap();
1279        // Builder drops the incompatible `fb` tag.
1280        assert!(parsed.free_busy.is_none());
1281        assert_eq!(parsed.status, RsvpStatus::Declined);
1282    }
1283
1284    #[test]
1285    fn wrong_kind_is_rejected() {
1286        let event = EventBuilder::text_note("nope")
1287            .sign_with_keys(&keys())
1288            .unwrap();
1289        assert!(matches!(
1290            DateCalendarEvent::from_event(&event),
1291            Err(CalendarError::WrongKind(_))
1292        ));
1293        assert!(matches!(
1294            TimeCalendarEvent::from_event(&event),
1295            Err(CalendarError::WrongKind(_))
1296        ));
1297        assert!(matches!(
1298            Calendar::from_event(&event),
1299            Err(CalendarError::WrongKind(_))
1300        ));
1301        assert!(matches!(
1302            Rsvp::from_event(&event),
1303            Err(CalendarError::WrongKind(_))
1304        ));
1305    }
1306
1307    #[test]
1308    fn missing_title_rejected() {
1309        let event = EventBuilder::new(KIND_DATE_EVENT, "")
1310            .tag(Tag::d("x"))
1311            .tag(Tag::with(&TagKind::from_wire(START_TAG), ["2025-01-01"]))
1312            .sign_with_keys(&keys())
1313            .unwrap();
1314        assert!(matches!(
1315            DateCalendarEvent::from_event(&event),
1316            Err(CalendarError::MissingTitle)
1317        ));
1318    }
1319}