1use 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
49pub const KIND_DATE_EVENT: Kind = Kind::CALENDAR_DATE_EVENT;
51
52pub const KIND_TIME_EVENT: Kind = Kind::CALENDAR_TIME_EVENT;
54
55pub const KIND_CALENDAR: Kind = Kind::CALENDAR;
57
58pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
80pub struct CalendarDate(String);
81
82impl CalendarDate {
83 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 Ok(Self(format!("{year:04}-{month:02}-{day:02}")))
118 }
119
120 #[must_use]
122 pub fn as_str(&self) -> &str {
123 &self.0
124 }
125
126 #[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#[derive(Debug, Error)]
149pub enum CalendarDateError {
150 #[error("calendar date must be 10 characters long (`YYYY-MM-DD`)")]
152 WrongLength,
153 #[error("calendar date must use `-` separators at positions 4 and 7")]
155 MissingSeparator,
156 #[error("calendar date month out of range: {0}")]
158 InvalidMonth(u8),
159 #[error("calendar date day out of range: {0}")]
161 InvalidDay(u8),
162 #[error(transparent)]
164 InvalidNumber(#[from] ParseIntError),
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Participant {
170 pub pubkey: PublicKey,
172 pub relay_hint: Option<RelayUrl>,
174 pub role: Option<String>,
177}
178
179impl Participant {
180 #[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 #[must_use]
192 pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
193 self.relay_hint = Some(relay);
194 self
195 }
196
197 #[must_use]
199 pub fn role(mut self, role: impl Into<String>) -> Self {
200 self.role = Some(role.into());
201 self
202 }
203
204 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct CalendarRequest {
249 pub calendar: Coordinate,
251 pub relay_hint: Option<RelayUrl>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Default)]
257pub struct CalendarEventCommon {
258 pub content: String,
260 pub identifier: String,
262 pub title: String,
264 pub summary: Option<String>,
266 pub image: Option<Url>,
268 pub locations: Vec<String>,
270 pub geohash: Option<String>,
272 pub participants: Vec<Participant>,
274 pub hashtags: Vec<String>,
276 pub references: Vec<Url>,
278 pub calendar_requests: Vec<CalendarRequest>,
280 pub extra_tags: Vec<Tag>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct DateCalendarEvent {
287 pub common: CalendarEventCommon,
289 pub start: CalendarDate,
291 pub end: Option<CalendarDate>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct TimeCalendarEvent {
299 pub common: CalendarEventCommon,
301 pub start: Timestamp,
303 pub end: Option<Timestamp>,
306 pub start_tzid: Option<String>,
308 pub end_tzid: Option<String>,
310}
311
312impl TimeCalendarEvent {
313 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
339pub struct Calendar {
340 pub identifier: String,
342 pub title: String,
344 pub content: String,
346 pub events: Vec<CalendarRequest>,
348 pub extra_tags: Vec<Tag>,
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
354pub enum RsvpStatus {
355 Accepted,
357 Declined,
359 Tentative,
361}
362
363impl RsvpStatus {
364 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
388pub enum FreeBusy {
389 Free,
391 Busy,
393}
394
395impl FreeBusy {
396 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct Rsvp {
419 pub identifier: String,
421 pub event_coordinate: Coordinate,
423 pub event_coordinate_relay_hint: Option<RelayUrl>,
425 pub event_id: Option<EventId>,
427 pub event_id_relay_hint: Option<RelayUrl>,
429 pub status: RsvpStatus,
431 pub free_busy: Option<FreeBusy>,
434 pub event_author: Option<PublicKey>,
436 pub event_author_relay_hint: Option<RelayUrl>,
438 pub content: String,
440 pub extra_tags: Vec<Tag>,
442}
443
444#[derive(Debug, Error)]
446#[non_exhaustive]
447pub enum CalendarError {
448 #[error("unexpected kind for NIP-52 event: {}", .0.as_u16())]
450 WrongKind(Kind),
451 #[error("NIP-52 event missing `d` tag")]
453 MissingIdentifier,
454 #[error("NIP-52 event missing `title` tag")]
456 MissingTitle,
457 #[error("NIP-52 event missing `start` tag")]
459 MissingStart,
460 #[error("NIP-52 RSVP missing calendar-event coordinate")]
462 MissingRsvpCoordinate,
463 #[error("NIP-52 RSVP missing `status` tag")]
465 MissingRsvpStatus,
466 #[error("invalid RSVP status: `{0}`")]
468 InvalidRsvpStatus(String),
469 #[error("invalid free/busy value: `{0}`")]
471 InvalidFreeBusy(String),
472 #[error("`p` participant tag missing pubkey")]
474 MalformedParticipant,
475 #[error("`a` tag missing coordinate")]
477 MalformedAddress,
478 #[error("`image` tag missing URL")]
480 MalformedImage,
481 #[error(transparent)]
483 InvalidCalendarDate(#[from] CalendarDateError),
484 #[error(transparent)]
486 InvalidTimestamp(#[from] TimestampError),
487 #[error(transparent)]
489 InvalidPublicKey(#[from] PublicKeyError),
490 #[error(transparent)]
492 InvalidCoordinate(#[from] CoordinateError),
493 #[error(transparent)]
495 InvalidEventId(#[from] EventIdError),
496 #[error(transparent)]
498 InvalidUrl(#[from] UrlError),
499 #[error(transparent)]
501 InvalidRelayUrl(#[from] RelayUrlError),
502}
503
504impl DateCalendarEvent {
505 #[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 #[must_use]
526 pub fn end(mut self, end: CalendarDate) -> Self {
527 self.end = Some(end);
528 self
529 }
530
531 #[must_use]
533 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
534 Coordinate::new(KIND_DATE_EVENT, author, self.common.identifier.clone())
535 }
536
537 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 #[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 #[must_use]
573 pub const fn end(mut self, end: Timestamp) -> Self {
574 self.end = Some(end);
575 self
576 }
577
578 #[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 #[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 #[must_use]
594 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
595 Coordinate::new(KIND_TIME_EVENT, author, self.common.identifier.clone())
596 }
597
598 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 #[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 #[must_use]
637 pub fn content(mut self, content: impl Into<String>) -> Self {
638 self.content = content.into();
639 self
640 }
641
642 #[must_use]
644 pub fn event(mut self, request: CalendarRequest) -> Self {
645 self.events.push(request);
646 self
647 }
648
649 #[must_use]
651 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
652 Coordinate::new(KIND_CALENDAR, author, self.identifier.clone())
653 }
654
655 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 #[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 #[must_use]
720 pub const fn free_busy(mut self, fb: FreeBusy) -> Self {
721 self.free_busy = Some(fb);
722 self
723 }
724
725 #[must_use]
727 pub const fn event_author(mut self, pubkey: PublicKey) -> Self {
728 self.event_author = Some(pubkey);
729 self
730 }
731
732 #[must_use]
734 pub const fn event_id(mut self, id: EventId) -> Self {
735 self.event_id = Some(id);
736 self
737 }
738
739 #[must_use]
741 pub fn content(mut self, content: impl Into<String>) -> Self {
742 self.content = content.into();
743 self
744 }
745
746 #[must_use]
748 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
749 Coordinate::new(KIND_RSVP, author, self.identifier.clone())
750 }
751
752 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 "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 #[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 #[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 #[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 #[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 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}