use std::{fmt, num::ParseIntError, str::FromStr};
use thiserror::Error;
use crate::event::{
Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
SingleLetterTag, Tag, TagKind, Tags,
};
use crate::key::{PublicKey, PublicKeyError};
use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError};
pub const KIND_DATE_EVENT: Kind = Kind::CALENDAR_DATE_EVENT;
pub const KIND_TIME_EVENT: Kind = Kind::CALENDAR_TIME_EVENT;
pub const KIND_CALENDAR: Kind = Kind::CALENDAR;
pub const KIND_RSVP: Kind = Kind::CALENDAR_RSVP;
const TITLE_TAG: &str = "title";
const SUMMARY_TAG: &str = "summary";
const IMAGE_TAG: &str = "image";
const LOCATION_TAG: &str = "location";
const START_TAG: &str = "start";
const END_TAG: &str = "end";
const START_TZID_TAG: &str = "start_tzid";
const END_TZID_TAG: &str = "end_tzid";
const STATUS_TAG: &str = "status";
const FREE_BUSY_TAG: &str = "fb";
const SECONDS_PER_DAY: i64 = 86_400;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CalendarDate(String);
impl CalendarDate {
pub fn parse(input: &str) -> Result<Self, CalendarDateError> {
if input.len() != 10 {
return Err(CalendarDateError::WrongLength);
}
let bytes = input.as_bytes();
if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') {
return Err(CalendarDateError::MissingSeparator);
}
let year: u16 = input
.get(0..4)
.ok_or(CalendarDateError::WrongLength)?
.parse()?;
let month: u8 = input
.get(5..7)
.ok_or(CalendarDateError::WrongLength)?
.parse()?;
let day: u8 = input
.get(8..10)
.ok_or(CalendarDateError::WrongLength)?
.parse()?;
if !(1..=12).contains(&month) {
return Err(CalendarDateError::InvalidMonth(month));
}
if !(1..=31).contains(&day) {
return Err(CalendarDateError::InvalidDay(day));
}
Ok(Self(format!("{year:04}-{month:02}-{day:02}")))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl fmt::Display for CalendarDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for CalendarDate {
type Err = CalendarDateError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::parse(input)
}
}
#[derive(Debug, Error)]
pub enum CalendarDateError {
#[error("calendar date must be 10 characters long (`YYYY-MM-DD`)")]
WrongLength,
#[error("calendar date must use `-` separators at positions 4 and 7")]
MissingSeparator,
#[error("calendar date month out of range: {0}")]
InvalidMonth(u8),
#[error("calendar date day out of range: {0}")]
InvalidDay(u8),
#[error(transparent)]
InvalidNumber(#[from] ParseIntError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Participant {
pub pubkey: PublicKey,
pub relay_hint: Option<RelayUrl>,
pub role: Option<String>,
}
impl Participant {
#[must_use]
pub const fn new(pubkey: PublicKey) -> Self {
Self {
pubkey,
relay_hint: None,
role: None,
}
}
#[must_use]
pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
self.relay_hint = Some(relay);
self
}
#[must_use]
pub fn role(mut self, role: impl Into<String>) -> Self {
self.role = Some(role.into());
self
}
#[must_use]
pub fn to_tag(&self) -> Tag {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
let relay = self
.relay_hint
.as_ref()
.map_or_else(String::new, |r| r.as_str().to_owned());
if let Some(role) = &self.role {
Tag::with(&head, [self.pubkey.to_hex(), relay, role.clone()])
} else if self.relay_hint.is_some() {
Tag::with(&head, [self.pubkey.to_hex(), relay])
} else {
Tag::with(&head, [self.pubkey.to_hex()])
}
}
pub fn from_tag(tag: &Tag) -> Result<Self, CalendarError> {
let pk_hex = tag.get(1).ok_or(CalendarError::MalformedParticipant)?;
let pubkey = PublicKey::parse(pk_hex)?;
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
let role = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
Ok(Self {
pubkey,
relay_hint,
role,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalendarRequest {
pub calendar: Coordinate,
pub relay_hint: Option<RelayUrl>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CalendarEventCommon {
pub content: String,
pub identifier: String,
pub title: String,
pub summary: Option<String>,
pub image: Option<Url>,
pub locations: Vec<String>,
pub geohash: Option<String>,
pub participants: Vec<Participant>,
pub hashtags: Vec<String>,
pub references: Vec<Url>,
pub calendar_requests: Vec<CalendarRequest>,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DateCalendarEvent {
pub common: CalendarEventCommon,
pub start: CalendarDate,
pub end: Option<CalendarDate>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeCalendarEvent {
pub common: CalendarEventCommon,
pub start: Timestamp,
pub end: Option<Timestamp>,
pub start_tzid: Option<String>,
pub end_tzid: Option<String>,
}
impl TimeCalendarEvent {
#[must_use]
pub fn day_floors(&self) -> Vec<i64> {
let start_day = i64::try_from(self.start.as_secs())
.unwrap_or(i64::MAX)
.div_euclid(SECONDS_PER_DAY);
let end_day = self.end.map_or(start_day, |e| {
i64::try_from(e.as_secs())
.unwrap_or(i64::MAX)
.div_euclid(SECONDS_PER_DAY)
});
if end_day < start_day {
return vec![start_day];
}
(start_day..=end_day).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Calendar {
pub identifier: String,
pub title: String,
pub content: String,
pub events: Vec<CalendarRequest>,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RsvpStatus {
Accepted,
Declined,
Tentative,
}
impl RsvpStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::Declined => "declined",
Self::Tentative => "tentative",
}
}
#[must_use]
pub fn parse(token: &str) -> Option<Self> {
match token {
"accepted" => Some(Self::Accepted),
"declined" => Some(Self::Declined),
"tentative" => Some(Self::Tentative),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FreeBusy {
Free,
Busy,
}
impl FreeBusy {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Free => "free",
Self::Busy => "busy",
}
}
#[must_use]
pub fn parse(token: &str) -> Option<Self> {
match token {
"free" => Some(Self::Free),
"busy" => Some(Self::Busy),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rsvp {
pub identifier: String,
pub event_coordinate: Coordinate,
pub event_coordinate_relay_hint: Option<RelayUrl>,
pub event_id: Option<EventId>,
pub event_id_relay_hint: Option<RelayUrl>,
pub status: RsvpStatus,
pub free_busy: Option<FreeBusy>,
pub event_author: Option<PublicKey>,
pub event_author_relay_hint: Option<RelayUrl>,
pub content: String,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CalendarError {
#[error("unexpected kind for NIP-52 event: {}", .0.as_u16())]
WrongKind(Kind),
#[error("NIP-52 event missing `d` tag")]
MissingIdentifier,
#[error("NIP-52 event missing `title` tag")]
MissingTitle,
#[error("NIP-52 event missing `start` tag")]
MissingStart,
#[error("NIP-52 RSVP missing calendar-event coordinate")]
MissingRsvpCoordinate,
#[error("NIP-52 RSVP missing `status` tag")]
MissingRsvpStatus,
#[error("invalid RSVP status: `{0}`")]
InvalidRsvpStatus(String),
#[error("invalid free/busy value: `{0}`")]
InvalidFreeBusy(String),
#[error("`p` participant tag missing pubkey")]
MalformedParticipant,
#[error("`a` tag missing coordinate")]
MalformedAddress,
#[error("`image` tag missing URL")]
MalformedImage,
#[error(transparent)]
InvalidCalendarDate(#[from] CalendarDateError),
#[error(transparent)]
InvalidTimestamp(#[from] TimestampError),
#[error(transparent)]
InvalidPublicKey(#[from] PublicKeyError),
#[error(transparent)]
InvalidCoordinate(#[from] CoordinateError),
#[error(transparent)]
InvalidEventId(#[from] EventIdError),
#[error(transparent)]
InvalidUrl(#[from] UrlError),
#[error(transparent)]
InvalidRelayUrl(#[from] RelayUrlError),
}
impl DateCalendarEvent {
#[must_use]
pub fn new(
identifier: impl Into<String>,
title: impl Into<String>,
start: CalendarDate,
) -> Self {
Self {
common: CalendarEventCommon {
identifier: identifier.into(),
title: title.into(),
..CalendarEventCommon::default()
},
start,
end: None,
}
}
#[must_use]
pub fn end(mut self, end: CalendarDate) -> Self {
self.end = Some(end);
self
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Coordinate {
Coordinate::new(KIND_DATE_EVENT, author, self.common.identifier.clone())
}
pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
if event.kind != KIND_DATE_EVENT {
return Err(CalendarError::WrongKind(event.kind));
}
let (common, mut state) = parse_common(event)?;
let start = state.start_date.take().ok_or(CalendarError::MissingStart)?;
let end = state.end_date.take();
Ok(Self { common, start, end })
}
}
impl TimeCalendarEvent {
#[must_use]
pub fn new(identifier: impl Into<String>, title: impl Into<String>, start: Timestamp) -> Self {
Self {
common: CalendarEventCommon {
identifier: identifier.into(),
title: title.into(),
..CalendarEventCommon::default()
},
start,
end: None,
start_tzid: None,
end_tzid: None,
}
}
#[must_use]
pub const fn end(mut self, end: Timestamp) -> Self {
self.end = Some(end);
self
}
#[must_use]
pub fn start_tzid(mut self, tzid: impl Into<String>) -> Self {
self.start_tzid = Some(tzid.into());
self
}
#[must_use]
pub fn end_tzid(mut self, tzid: impl Into<String>) -> Self {
self.end_tzid = Some(tzid.into());
self
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Coordinate {
Coordinate::new(KIND_TIME_EVENT, author, self.common.identifier.clone())
}
pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
if event.kind != KIND_TIME_EVENT {
return Err(CalendarError::WrongKind(event.kind));
}
let (common, mut state) = parse_common(event)?;
let start = state.start_ts.take().ok_or(CalendarError::MissingStart)?;
let end = state.end_ts.take();
let start_tzid = state.start_tzid.take();
let end_tzid = state.end_tzid.take();
Ok(Self {
common,
start,
end,
start_tzid,
end_tzid,
})
}
}
impl Calendar {
#[must_use]
pub fn new(identifier: impl Into<String>, title: impl Into<String>) -> Self {
Self {
identifier: identifier.into(),
title: title.into(),
content: String::new(),
events: Vec::new(),
extra_tags: Vec::new(),
}
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn event(mut self, request: CalendarRequest) -> Self {
self.events.push(request);
self
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Coordinate {
Coordinate::new(KIND_CALENDAR, author, self.identifier.clone())
}
pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
if event.kind != KIND_CALENDAR {
return Err(CalendarError::WrongKind(event.kind));
}
let identifier = d_value(&event.tags)
.ok_or(CalendarError::MissingIdentifier)?
.to_owned();
let mut out = Self {
identifier,
content: event.content.clone(),
..Self::default()
};
for tag in &event.tags {
match tag.kind() {
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
out.events.push(parse_calendar_request(tag)?);
}
_ if tag.name() == TITLE_TAG => {
out.title = tag.get(1).map(str::to_owned).unwrap_or_default();
}
_ => out.extra_tags.push(tag.clone()),
}
}
if out.title.is_empty() {
return Err(CalendarError::MissingTitle);
}
Ok(out)
}
}
impl Rsvp {
#[must_use]
pub fn new(
identifier: impl Into<String>,
event_coordinate: Coordinate,
status: RsvpStatus,
) -> Self {
Self {
identifier: identifier.into(),
event_coordinate,
event_coordinate_relay_hint: None,
event_id: None,
event_id_relay_hint: None,
status,
free_busy: None,
event_author: None,
event_author_relay_hint: None,
content: String::new(),
extra_tags: Vec::new(),
}
}
#[must_use]
pub const fn free_busy(mut self, fb: FreeBusy) -> Self {
self.free_busy = Some(fb);
self
}
#[must_use]
pub const fn event_author(mut self, pubkey: PublicKey) -> Self {
self.event_author = Some(pubkey);
self
}
#[must_use]
pub const fn event_id(mut self, id: EventId) -> Self {
self.event_id = Some(id);
self
}
#[must_use]
pub fn content(mut self, content: impl Into<String>) -> Self {
self.content = content.into();
self
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Coordinate {
Coordinate::new(KIND_RSVP, author, self.identifier.clone())
}
pub fn from_event(event: &Event) -> Result<Self, CalendarError> {
if event.kind != KIND_RSVP {
return Err(CalendarError::WrongKind(event.kind));
}
let identifier = d_value(&event.tags)
.ok_or(CalendarError::MissingIdentifier)?
.to_owned();
let mut coord: Option<(Coordinate, Option<RelayUrl>)> = None;
let mut evid: Option<(EventId, Option<RelayUrl>)> = None;
let mut status: Option<RsvpStatus> = None;
let mut free_busy: Option<FreeBusy> = None;
let mut author: Option<(PublicKey, Option<RelayUrl>)> = None;
let mut extra_tags: Vec<Tag> = Vec::new();
for tag in &event.tags {
match tag.kind() {
TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
TagKind::SingleLetter(s)
if !s.uppercase && s.character == Alphabet::A && coord.is_none() =>
{
let req = parse_calendar_request(tag)?;
coord = Some((req.calendar, req.relay_hint));
}
TagKind::SingleLetter(s)
if !s.uppercase && s.character == Alphabet::E && evid.is_none() =>
{
evid = Some(parse_event_ref(tag)?);
}
TagKind::SingleLetter(s)
if !s.uppercase && s.character == Alphabet::P && author.is_none() =>
{
author = Some(parse_pubkey_ref(tag)?);
}
_ if tag.name() == STATUS_TAG => {
let raw = tag.get(1).ok_or(CalendarError::MissingRsvpStatus)?;
status = Some(
RsvpStatus::parse(raw)
.ok_or_else(|| CalendarError::InvalidRsvpStatus(raw.to_owned()))?,
);
}
_ if tag.name() == FREE_BUSY_TAG => free_busy = parse_free_busy_tag(tag)?,
_ => extra_tags.push(tag.clone()),
}
}
let (event_coordinate, event_coordinate_relay_hint) =
coord.ok_or(CalendarError::MissingRsvpCoordinate)?;
let status = status.ok_or(CalendarError::MissingRsvpStatus)?;
let (event_id, event_id_relay_hint) =
evid.map_or((None, None), |(id, relay)| (Some(id), relay));
let (event_author, event_author_relay_hint) =
author.map_or((None, None), |(pk, relay)| (Some(pk), relay));
Ok(Self {
identifier,
event_coordinate,
event_coordinate_relay_hint,
event_id,
event_id_relay_hint,
status,
free_busy,
event_author,
event_author_relay_hint,
content: event.content.clone(),
extra_tags,
})
}
}
#[derive(Default)]
struct CommonParseState {
start_date: Option<CalendarDate>,
end_date: Option<CalendarDate>,
start_ts: Option<Timestamp>,
end_ts: Option<Timestamp>,
start_tzid: Option<String>,
end_tzid: Option<String>,
}
fn parse_common(event: &Event) -> Result<(CalendarEventCommon, CommonParseState), CalendarError> {
let identifier = d_value(&event.tags)
.ok_or(CalendarError::MissingIdentifier)?
.to_owned();
let mut common = CalendarEventCommon {
identifier,
content: event.content.clone(),
..CalendarEventCommon::default()
};
let mut state = CommonParseState::default();
for tag in &event.tags {
if absorb_common_single_letter(tag, &mut common)? {
continue;
}
absorb_common_named_tag(tag, event.kind, &mut common, &mut state)?;
}
if common.title.is_empty() {
return Err(CalendarError::MissingTitle);
}
Ok((common, state))
}
fn absorb_common_single_letter(
tag: &Tag,
common: &mut CalendarEventCommon,
) -> Result<bool, CalendarError> {
let TagKind::SingleLetter(s) = tag.kind() else {
return Ok(false);
};
if s.uppercase {
return Ok(false);
}
match s.character {
Alphabet::D => Ok(true),
Alphabet::G => {
common.geohash = tag.get(1).map(str::to_owned);
Ok(true)
}
Alphabet::P => {
common.participants.push(Participant::from_tag(tag)?);
Ok(true)
}
Alphabet::T => {
if let Some(raw) = tag.get(1) {
common.hashtags.push(raw.to_ascii_lowercase());
}
Ok(true)
}
Alphabet::R => {
if let Some(raw) = tag.get(1) {
common.references.push(Url::parse(raw)?);
}
Ok(true)
}
Alphabet::A => {
common.calendar_requests.push(parse_calendar_request(tag)?);
Ok(true)
}
_ => Ok(false),
}
}
fn absorb_common_named_tag(
tag: &Tag,
kind: Kind,
common: &mut CalendarEventCommon,
state: &mut CommonParseState,
) -> Result<(), CalendarError> {
match tag.name() {
TITLE_TAG => {
common.title = tag.get(1).map(str::to_owned).unwrap_or_default();
}
SUMMARY_TAG => common.summary = tag.get(1).map(str::to_owned),
IMAGE_TAG => {
let raw = tag.get(1).ok_or(CalendarError::MalformedImage)?;
common.image = Some(Url::parse(raw)?);
}
LOCATION_TAG => {
if let Some(raw) = tag.get(1) {
common.locations.push(raw.to_owned());
}
}
START_TAG => parse_start_tag(kind, tag, state)?,
END_TAG => parse_end_tag(kind, tag, state)?,
START_TZID_TAG => state.start_tzid = tag.get(1).map(str::to_owned),
END_TZID_TAG => state.end_tzid = tag.get(1).map(str::to_owned),
"D" => {}
_ => common.extra_tags.push(tag.clone()),
}
Ok(())
}
fn parse_free_busy_tag(tag: &Tag) -> Result<Option<FreeBusy>, CalendarError> {
let Some(raw) = tag.get(1) else {
return Ok(None);
};
let fb = FreeBusy::parse(raw).ok_or_else(|| CalendarError::InvalidFreeBusy(raw.to_owned()))?;
Ok(Some(fb))
}
fn parse_start_tag(
kind: Kind,
tag: &Tag,
state: &mut CommonParseState,
) -> Result<(), CalendarError> {
let Some(raw) = tag.get(1) else {
return Ok(());
};
if kind == KIND_DATE_EVENT {
state.start_date = Some(CalendarDate::parse(raw)?);
} else {
state.start_ts = Some(raw.parse::<Timestamp>()?);
}
Ok(())
}
fn parse_end_tag(kind: Kind, tag: &Tag, state: &mut CommonParseState) -> Result<(), CalendarError> {
let Some(raw) = tag.get(1) else {
return Ok(());
};
if kind == KIND_DATE_EVENT {
state.end_date = Some(CalendarDate::parse(raw)?);
} else {
state.end_ts = Some(raw.parse::<Timestamp>()?);
}
Ok(())
}
fn parse_calendar_request(tag: &Tag) -> Result<CalendarRequest, CalendarError> {
let coord_str = tag.get(1).ok_or(CalendarError::MalformedAddress)?;
let calendar = Coordinate::parse(coord_str)?;
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
Ok(CalendarRequest {
calendar,
relay_hint,
})
}
fn parse_event_ref(tag: &Tag) -> Result<(EventId, Option<RelayUrl>), CalendarError> {
let id_hex = tag.get(1).ok_or(CalendarError::MalformedAddress)?;
let id = EventId::parse(id_hex)?;
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
Ok((id, relay_hint))
}
fn parse_pubkey_ref(tag: &Tag) -> Result<(PublicKey, Option<RelayUrl>), CalendarError> {
let pk_hex = tag.get(1).ok_or(CalendarError::MalformedParticipant)?;
let pubkey = PublicKey::parse(pk_hex)?;
let relay_hint = match tag.get(2) {
Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
_ => None,
};
Ok((pubkey, relay_hint))
}
fn d_value(tags: &Tags) -> Option<&str> {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
tags.find_first(&head).and_then(|tag| tag.get(1))
}
fn apply_common(common: &CalendarEventCommon, mut builder: EventBuilder) -> EventBuilder {
if let Some(summary) = &common.summary {
builder = builder.tag(Tag::with(
&TagKind::from_wire(SUMMARY_TAG),
[summary.clone()],
));
}
if let Some(image) = &common.image {
builder = builder.tag(Tag::with(
&TagKind::from_wire(IMAGE_TAG),
[image.as_str().to_owned()],
));
}
for location in &common.locations {
builder = builder.tag(Tag::with(
&TagKind::from_wire(LOCATION_TAG),
[location.clone()],
));
}
if let Some(g) = &common.geohash {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G));
builder = builder.tag(Tag::with(&head, [g.clone()]));
}
for participant in &common.participants {
builder = builder.tag(participant.to_tag());
}
for hashtag in &common.hashtags {
builder = builder.tag(Tag::t(hashtag));
}
for url in &common.references {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
builder = builder.tag(Tag::with(&head, [url.as_str().to_owned()]));
}
for req in &common.calendar_requests {
builder = builder.tag(calendar_request_tag(req));
}
for tag in &common.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
fn calendar_request_tag(req: &CalendarRequest) -> Tag {
let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
req.relay_hint.as_ref().map_or_else(
|| Tag::with(&head, [req.calendar.to_wire()]),
|relay| Tag::with(&head, [req.calendar.to_wire(), relay.as_str().to_owned()]),
)
}
impl EventBuilder {
#[must_use]
pub fn calendar_date_event(event: &DateCalendarEvent) -> Self {
let mut builder = Self::new(KIND_DATE_EVENT, event.common.content.clone());
builder = builder
.tag(Tag::d(&event.common.identifier))
.tag(Tag::with(
&TagKind::from_wire(TITLE_TAG),
[event.common.title.clone()],
))
.tag(Tag::with(
&TagKind::from_wire(START_TAG),
[event.start.as_str().to_owned()],
));
if let Some(end) = &event.end {
builder = builder.tag(Tag::with(
&TagKind::from_wire(END_TAG),
[end.as_str().to_owned()],
));
}
apply_common(&event.common, builder)
}
#[must_use]
pub fn calendar_time_event(event: &TimeCalendarEvent) -> Self {
let mut builder = Self::new(KIND_TIME_EVENT, event.common.content.clone());
builder = builder
.tag(Tag::d(&event.common.identifier))
.tag(Tag::with(
&TagKind::from_wire(TITLE_TAG),
[event.common.title.clone()],
))
.tag(Tag::with(
&TagKind::from_wire(START_TAG),
[event.start.as_secs().to_string()],
));
if let Some(end) = event.end {
builder = builder.tag(Tag::with(
&TagKind::from_wire(END_TAG),
[end.as_secs().to_string()],
));
}
for floor in event.day_floors() {
builder = builder.tag(Tag::with(&TagKind::from_wire("D"), [floor.to_string()]));
}
if let Some(tzid) = &event.start_tzid {
builder = builder.tag(Tag::with(
&TagKind::from_wire(START_TZID_TAG),
[tzid.clone()],
));
}
if let Some(tzid) = &event.end_tzid {
builder = builder.tag(Tag::with(&TagKind::from_wire(END_TZID_TAG), [tzid.clone()]));
}
apply_common(&event.common, builder)
}
#[must_use]
pub fn calendar(calendar: &Calendar) -> Self {
let mut builder = Self::new(KIND_CALENDAR, calendar.content.clone())
.tag(Tag::d(&calendar.identifier))
.tag(Tag::with(
&TagKind::from_wire(TITLE_TAG),
[calendar.title.clone()],
));
for req in &calendar.events {
builder = builder.tag(calendar_request_tag(req));
}
for tag in &calendar.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
#[must_use]
pub fn calendar_rsvp(rsvp: &Rsvp) -> Self {
let mut builder = Self::new(KIND_RSVP, rsvp.content.clone());
builder = builder
.tag(Tag::d(&rsvp.identifier))
.tag(calendar_request_tag(&CalendarRequest {
calendar: rsvp.event_coordinate.clone(),
relay_hint: rsvp.event_coordinate_relay_hint.clone(),
}))
.tag(Tag::with(
&TagKind::from_wire(STATUS_TAG),
[rsvp.status.as_str().to_owned()],
));
if let Some(id) = rsvp.event_id {
let head_e = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
builder = builder.tag(rsvp.event_id_relay_hint.as_ref().map_or_else(
|| Tag::with(&head_e, [id.to_hex()]),
|relay| Tag::with(&head_e, [id.to_hex(), relay.as_str().to_owned()]),
));
}
if let Some(fb) = rsvp.free_busy
&& rsvp.status != RsvpStatus::Declined
{
builder = builder.tag(Tag::with(
&TagKind::from_wire(FREE_BUSY_TAG),
[fb.as_str().to_owned()],
));
}
if let Some(pk) = rsvp.event_author {
let head_p = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
builder = builder.tag(rsvp.event_author_relay_hint.as_ref().map_or_else(
|| Tag::with(&head_p, [pk.to_hex()]),
|relay| Tag::with(&head_p, [pk.to_hex(), relay.as_str().to_owned()]),
));
}
for tag in &rsvp.extra_tags {
builder = builder.tag(tag.clone());
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
#[test]
fn calendar_date_parses() {
let d = CalendarDate::parse("2025-01-09").unwrap();
assert_eq!(d.as_str(), "2025-01-09");
assert!(matches!(
CalendarDate::parse("2025/01/09"),
Err(CalendarDateError::MissingSeparator)
));
assert!(matches!(
CalendarDate::parse("2025-13-09"),
Err(CalendarDateError::InvalidMonth(13))
));
}
#[test]
fn date_event_round_trip() {
let event = DateCalendarEvent::new(
"holiday",
"Holiday",
CalendarDate::parse("2025-12-24").unwrap(),
)
.end(CalendarDate::parse("2025-12-26").unwrap());
let signed = EventBuilder::calendar_date_event(&event)
.sign_with_keys(&keys())
.unwrap();
let parsed = DateCalendarEvent::from_event(&signed).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn time_event_round_trip_with_participants() {
let start = Timestamp::from_secs(1_700_000_000);
let end = Timestamp::from_secs(1_700_003_600);
let participant = Participant::new(*keys().public_key())
.relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
.role("Speaker");
let mut event = TimeCalendarEvent::new("meet", "Meet", start)
.end(end)
.start_tzid("America/Costa_Rica")
.end_tzid("America/Costa_Rica");
event.common.participants.push(participant);
event.common.summary = Some("brief".into());
event.common.hashtags.push("ethereum".into());
event.common.locations.push("online".into());
let signed = EventBuilder::calendar_time_event(&event)
.sign_with_keys(&keys())
.unwrap();
let parsed = TimeCalendarEvent::from_event(&signed).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn time_event_day_floors_cover_range() {
let start = Timestamp::from_secs(86_400 * 10);
let end = Timestamp::from_secs(86_400 * 12);
let event = TimeCalendarEvent::new("multi", "Multi", start).end(end);
assert_eq!(event.day_floors(), vec![10, 11, 12]);
}
#[test]
fn calendar_round_trip() {
let coord = Coordinate::new(KIND_TIME_EVENT, *keys().public_key(), "meet");
let calendar =
Calendar::new("cal-1", "Work")
.content("description")
.event(CalendarRequest {
calendar: coord,
relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
});
let signed = EventBuilder::calendar(&calendar)
.sign_with_keys(&keys())
.unwrap();
let parsed = Calendar::from_event(&signed).unwrap();
assert_eq!(parsed, calendar);
}
#[test]
fn rsvp_round_trip() {
let coord = Coordinate::new(KIND_TIME_EVENT, *keys().public_key(), "meet");
let rsvp = Rsvp::new("rsvp-1", coord, RsvpStatus::Accepted)
.free_busy(FreeBusy::Busy)
.event_author(*keys().public_key())
.event_id(EventId::from_byte_array([0xcc; 32]))
.content("see you");
let signed = EventBuilder::calendar_rsvp(&rsvp)
.sign_with_keys(&keys())
.unwrap();
let parsed = Rsvp::from_event(&signed).unwrap();
assert_eq!(parsed, rsvp);
}
#[test]
fn declined_rsvp_omits_fb() {
let coord = Coordinate::new(KIND_TIME_EVENT, *keys().public_key(), "meet");
let rsvp = Rsvp::new("rsvp-2", coord, RsvpStatus::Declined).free_busy(FreeBusy::Free);
let signed = EventBuilder::calendar_rsvp(&rsvp)
.sign_with_keys(&keys())
.unwrap();
let parsed = Rsvp::from_event(&signed).unwrap();
assert!(parsed.free_busy.is_none());
assert_eq!(parsed.status, RsvpStatus::Declined);
}
#[test]
fn wrong_kind_is_rejected() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
DateCalendarEvent::from_event(&event),
Err(CalendarError::WrongKind(_))
));
assert!(matches!(
TimeCalendarEvent::from_event(&event),
Err(CalendarError::WrongKind(_))
));
assert!(matches!(
Calendar::from_event(&event),
Err(CalendarError::WrongKind(_))
));
assert!(matches!(
Rsvp::from_event(&event),
Err(CalendarError::WrongKind(_))
));
}
#[test]
fn missing_title_rejected() {
let event = EventBuilder::new(KIND_DATE_EVENT, "")
.tag(Tag::d("x"))
.tag(Tag::with(&TagKind::from_wire(START_TAG), ["2025-01-01"]))
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
DateCalendarEvent::from_event(&event),
Err(CalendarError::MissingTitle)
));
}
}