use crate::{
common::timestamp,
models::calendar::StyledDescription,
models::location::EventLocation,
traits::{HasIdPath, TimestampId, Validatable},
validation::{is_valid_datetime, is_valid_duration, is_valid_timezone},
EVENTKY_PATH, MAX_CALENDAR_URIS, MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_LOCATIONS,
MAX_EVENT_SUMMARY_LENGTH, MAX_EVENT_UID_LENGTH, MIN_EVENT_SUMMARY_LENGTH, MIN_EVENT_UID_LENGTH,
PUBLIC_PATH,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[cfg(target_arch = "wasm32")]
use crate::traits::Json;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
const MIN_UID_LENGTH: usize = MIN_EVENT_UID_LENGTH;
const MAX_UID_LENGTH: usize = MAX_EVENT_UID_LENGTH;
const MIN_SUMMARY_LENGTH: usize = MIN_EVENT_SUMMARY_LENGTH;
const MAX_SUMMARY_LENGTH: usize = MAX_EVENT_SUMMARY_LENGTH;
const MAX_DESCRIPTION_LENGTH: usize = MAX_EVENT_DESCRIPTION_LENGTH;
const MAX_LOCATIONS: usize = MAX_EVENT_LOCATIONS;
const VALID_STATUS: &[&str] = &["CONFIRMED", "TENTATIVE", "CANCELLED"];
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct PubkyAppEvent {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub uid: String, pub dtstamp: i64, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub dtstart: String, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub summary: String,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub dtend: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub duration: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub dtstart_tzid: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub dtend_tzid: Option<String>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub description: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub status: Option<String>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub locations: Option<Vec<EventLocation>>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub image_uri: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub url: Option<String>,
pub sequence: Option<i32>, pub last_modified: Option<i64>, pub created: Option<i64>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub rrule: Option<String>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub rdate: Option<Vec<String>>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub exdate: Option<Vec<String>>, #[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub recurrence_id: Option<String>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub styled_description: Option<StyledDescription>,
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(skip))]
pub x_pubky_calendar_uris: Option<Vec<String>>, }
impl PubkyAppEvent {
pub fn new(uid: String, dtstart: String, summary: String) -> Self {
let now = timestamp();
Self {
uid,
dtstamp: now,
dtstart,
summary,
dtend: None,
duration: None,
dtstart_tzid: None,
dtend_tzid: None,
description: None,
status: Some("CONFIRMED".to_string()),
locations: None,
image_uri: None,
url: None,
sequence: Some(0),
last_modified: Some(now),
created: Some(now),
rrule: None,
rdate: None,
exdate: None,
recurrence_id: None,
styled_description: None,
x_pubky_calendar_uris: None,
}
.sanitize()
}
pub fn with_end_time(mut self, dtend: String) -> Self {
self.dtend = Some(dtend);
self.sanitize()
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self.sanitize()
}
pub fn with_locations(mut self, locations: Vec<EventLocation>) -> Self {
self.locations = Some(locations);
self.sanitize()
}
pub fn with_location_item(mut self, location: EventLocation) -> Self {
self.locations = Some(vec![location]);
self.sanitize()
}
pub fn with_status(mut self, status: String) -> Self {
self.status = Some(status);
self.sanitize()
}
}
#[cfg(target_arch = "wasm32")]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppEvent {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn uid(&self) -> String {
self.uid.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn summary(&self) -> String {
self.summary.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn dtstart(&self) -> String {
self.dtstart.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn dtend(&self) -> Option<String> {
self.dtend.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn duration(&self) -> Option<String> {
self.duration.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn dtstart_tzid(&self) -> Option<String> {
self.dtstart_tzid.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn dtend_tzid(&self) -> Option<String> {
self.dtend_tzid.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn description(&self) -> Option<String> {
self.description.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn status(&self) -> Option<String> {
self.status.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn locations(&self) -> Option<Vec<EventLocation>> {
self.locations.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn image_uri(&self) -> Option<String> {
self.image_uri.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn url(&self) -> Option<String> {
self.url.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn rrule(&self) -> Option<String> {
self.rrule.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn rdate(&self) -> Option<Vec<String>> {
self.rdate.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn exdate(&self) -> Option<Vec<String>> {
self.exdate.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn styled_description(&self) -> Option<StyledDescription> {
self.styled_description.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(getter))]
pub fn x_pubky_calendar_uris(&self) -> Option<Vec<String>> {
self.x_pubky_calendar_uris.clone()
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = fromJson))]
pub fn from_json(js_value: &JsValue) -> Result<Self, String> {
Self::import_json(js_value)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = toJson))]
pub fn to_json(&self) -> Result<JsValue, String> {
self.export_json()
}
}
#[cfg(target_arch = "wasm32")]
impl Json for PubkyAppEvent {}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
impl PubkyAppEvent {
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(constructor))]
pub fn new_wasm(uid: String, dtstart: String, summary: String) -> Self {
Self::new(uid, dtstart, summary)
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = createId))]
pub fn create_id_wasm(&self) -> String {
self.create_id()
}
}
impl TimestampId for PubkyAppEvent {}
impl HasIdPath for PubkyAppEvent {
const PATH_SEGMENT: &'static str = "events/";
fn create_path(id: &str) -> String {
[PUBLIC_PATH, EVENTKY_PATH, Self::PATH_SEGMENT, id].concat()
}
}
impl Validatable for PubkyAppEvent {
fn sanitize(self) -> Self {
let uid = self.uid.trim().chars().take(MAX_UID_LENGTH).collect();
let summary = self
.summary
.trim()
.chars()
.take(MAX_SUMMARY_LENGTH)
.collect();
let dtstart = self.dtstart.trim().to_string();
let dtend = self.dtend.map(|dt| dt.trim().to_string());
let description = self
.description
.map(|desc| desc.trim().chars().take(MAX_DESCRIPTION_LENGTH).collect());
let status = self.status.map(|s| {
let s = s.trim().to_uppercase();
if VALID_STATUS.contains(&s.as_str()) {
s
} else {
"CONFIRMED".to_string() }
});
let image_uri = self.image_uri.and_then(|uri| match Url::parse(uri.trim()) {
Ok(url) => Some(url.to_string()),
Err(_) => None,
});
let url = self.url.and_then(|uri| match Url::parse(uri.trim()) {
Ok(url) => Some(url.to_string()),
Err(_) => None,
});
let x_pubky_calendar_uris = self
.x_pubky_calendar_uris
.map(|uris| {
uris.into_iter()
.take(MAX_CALENDAR_URIS)
.filter_map(|uri| match Url::parse(uri.trim()) {
Ok(url) => Some(url.to_string()),
Err(_) => None,
})
.collect::<Vec<_>>()
})
.filter(|uris| !uris.is_empty());
let dtstart_tzid = self.dtstart_tzid.and_then(|tz| {
if is_valid_timezone(tz.trim()) {
Some(tz.trim().to_string())
} else {
None
}
});
let dtend_tzid = self.dtend_tzid.and_then(|tz| {
if is_valid_timezone(tz.trim()) {
Some(tz.trim().to_string())
} else {
None
}
});
let duration = self.duration.and_then(|dur| {
if is_valid_duration(dur.trim()) {
Some(dur.trim().to_string())
} else {
None
}
});
let locations = self
.locations
.map(|locs| {
locs.into_iter()
.take(MAX_LOCATIONS)
.map(|loc| loc.sanitize())
.collect::<Vec<_>>()
})
.filter(|locs| !locs.is_empty());
Self {
uid,
dtstamp: self.dtstamp,
dtstart,
summary,
dtend,
duration,
dtstart_tzid,
dtend_tzid,
description,
status,
locations,
image_uri,
url,
sequence: self.sequence,
last_modified: self.last_modified,
created: self.created,
rrule: self.rrule,
rdate: self.rdate,
exdate: self.exdate,
recurrence_id: self.recurrence_id,
styled_description: self.styled_description,
x_pubky_calendar_uris,
}
}
fn validate(&self, id: Option<&str>) -> Result<(), String> {
if let Some(id) = id {
self.validate_id(id)?;
}
let uid_length = self.uid.chars().count();
if !(MIN_UID_LENGTH..=MAX_UID_LENGTH).contains(&uid_length) {
return Err(
"Validation Error: Event UID length must be between 1 and 255 characters".into(),
);
}
let summary_length = self.summary.chars().count();
if !(MIN_SUMMARY_LENGTH..=MAX_SUMMARY_LENGTH).contains(&summary_length) {
return Err(
"Validation Error: Event summary length must be between 1 and 500 characters"
.into(),
);
}
if !is_valid_datetime(&self.dtstart) {
return Err("Validation Error: Invalid start date-time format. Must be ISO 8601 (YYYY-MM-DDTHH:MM:SS)".into());
}
if let Some(ref dtend) = self.dtend {
if !is_valid_datetime(dtend) {
return Err("Validation Error: Invalid end date-time format. Must be ISO 8601 (YYYY-MM-DDTHH:MM:SS)".into());
}
if dtend <= &self.dtstart {
return Err("Validation Error: Event end time must be after start time".into());
}
}
if self.dtend.is_some() && self.duration.is_some() {
return Err("Validation Error: Event cannot have both dtend and duration".into());
}
if let Some(status) = &self.status {
if !VALID_STATUS.contains(&status.as_str()) {
return Err("Validation Error: Invalid event status".into());
}
}
if let Some(desc) = &self.description {
if desc.chars().count() > MAX_DESCRIPTION_LENGTH {
return Err("Validation Error: Event description exceeds maximum length".into());
}
}
if let Some(locations) = &self.locations {
if locations.len() > MAX_LOCATIONS {
return Err(format!(
"Validation Error: Too many locations (max {})",
MAX_LOCATIONS
));
}
for (i, loc) in locations.iter().enumerate() {
if let Err(e) = loc.validate(None) {
return Err(format!("Validation Error: Location {}: {}", i + 1, e));
}
}
}
if let Some(cal_uris) = &self.x_pubky_calendar_uris {
if cal_uris.len() > MAX_CALENDAR_URIS {
return Err("Validation Error: Too many calendar URIs".into());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Validatable;
#[test]
fn test_new_simple() {
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
);
assert_eq!(event.uid, "event-123");
assert_eq!(event.dtstart, "2025-12-01T10:00:00");
assert_eq!(event.summary, "Team Meeting");
assert_eq!(event.status, Some("CONFIRMED".to_string()));
assert!(event.created.is_some());
let now = timestamp();
assert!(event.dtstamp <= now && event.dtstamp >= now - 1_000_000);
}
#[test]
fn test_new_complex() {
use crate::EventLocation;
let event = PubkyAppEvent::new(
"complex-event-456".to_string(),
"2025-12-01T14:00:00".to_string(),
"Annual Conference".to_string(),
)
.with_end_time("2025-12-01T18:00:00".to_string())
.with_description("Annual company conference with presentations".to_string())
.with_location_item(EventLocation::physical("Convention Center"));
assert_eq!(event.uid, "complex-event-456");
assert_eq!(event.dtstart, "2025-12-01T14:00:00");
assert_eq!(event.dtend, Some("2025-12-01T18:00:00".to_string()));
assert_eq!(event.summary, "Annual Conference");
assert!(event.locations.is_some());
assert_eq!(
event.locations.as_ref().unwrap()[0].label,
"Convention Center"
);
}
#[test]
fn test_create_id() {
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
);
let event_id = event.create_id();
println!("Generated Event ID: {}", event_id);
assert_eq!(event_id.len(), 13);
}
#[test]
fn test_create_path() {
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
);
let event_id = event.create_id();
let path = PubkyAppEvent::create_path(&event_id);
let prefix = format!("{}{}events/", PUBLIC_PATH, EVENTKY_PATH);
assert!(path.starts_with(&prefix));
let expected_path_len = prefix.len() + event_id.len();
assert_eq!(path.len(), expected_path_len);
}
#[test]
fn test_validate_valid() {
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
);
let id = event.create_id();
let result = event.validate(Some(&id));
assert!(result.is_ok());
}
#[test]
fn test_validate_invalid_uid() {
let event = PubkyAppEvent::new(
"".to_string(), "2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
);
let id = event.create_id();
let result = event.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("UID length"));
}
#[test]
fn test_validate_invalid_summary() {
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"".to_string(), );
let id = event.create_id();
let result = event.validate(Some(&id));
assert!(result.is_err());
assert!(result.unwrap_err().contains("summary length"));
}
#[test]
fn test_validate_invalid_time_order() {
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
)
.with_end_time("2025-12-01T09:00:00".to_string());
let id = event.create_id();
let result = event.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("end time must be after start time"));
}
#[test]
fn test_validate_both_dtend_and_duration() {
let mut event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
)
.with_end_time("2025-12-01T11:00:00".to_string());
event.duration = Some("PT1H".to_string());
let id = event.create_id();
let result = event.validate(Some(&id));
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("cannot have both dtend and duration"));
}
#[test]
fn test_sanitize() {
let event = PubkyAppEvent::new(
" event-123 ".to_string(), " 2025-12-01T10:00:00 ".to_string(), " Team Meeting ".to_string(), )
.with_description(" Meeting description ".to_string())
.with_status(" confirmed ".to_string());
assert_eq!(event.uid, "event-123");
assert_eq!(event.dtstart, "2025-12-01T10:00:00");
assert_eq!(event.summary, "Team Meeting");
assert_eq!(event.description, Some("Meeting description".to_string()));
assert_eq!(event.status, Some("CONFIRMED".to_string()));
}
#[test]
fn test_timezone_validation() {
assert!(is_valid_timezone("Europe/Zurich"));
assert!(is_valid_timezone("America/New_York"));
assert!(is_valid_timezone("Asia/Tokyo"));
assert!(!is_valid_timezone("")); assert!(!is_valid_timezone("Invalid")); assert!(!is_valid_timezone("Europe@Zurich")); }
#[test]
fn test_duration_validation() {
assert!(is_valid_duration("PT1H")); assert!(is_valid_duration("PT30M")); assert!(is_valid_duration("P1D")); assert!(is_valid_duration("P1DT2H30M")); assert!(!is_valid_duration("1H")); assert!(!is_valid_duration("")); assert!(!is_valid_duration("PT@H")); }
#[test]
fn test_try_from_valid() {
let event_json = r##"
{
"uid": "event-123",
"dtstamp": 1700000000000,
"dtstart": "2025-12-01T10:00:00",
"summary": "Team Meeting",
"dtend": "2025-12-01T11:00:00",
"duration": null,
"dtstart_tzid": "Europe/Zurich",
"dtend_tzid": "Europe/Zurich",
"description": "Weekly team sync meeting",
"status": "CONFIRMED",
"locations": [{"label": "Conference Room A", "kind": "PHYSICAL"}],
"image_uri": null,
"url": "https://example.com/meeting",
"sequence": 0,
"last_modified": 1700000000000,
"created": 1700000000000,
"rrule": null,
"rdate": null,
"exdate": null,
"recurrence_id": null,
"styled_description": null,
"x_pubky_calendar_uris": null
}
"##;
let event = PubkyAppEvent::new(
"event-123".to_string(),
"2025-12-01T10:00:00".to_string(),
"Team Meeting".to_string(),
)
.with_end_time("2025-12-01T11:00:00".to_string());
let id = event.create_id();
let blob = event_json.as_bytes();
let event_parsed = <PubkyAppEvent as Validatable>::try_from(blob, &id).unwrap();
assert_eq!(event_parsed.uid, "event-123");
assert_eq!(event_parsed.dtstart, "2025-12-01T10:00:00");
assert_eq!(event_parsed.summary, "Team Meeting");
assert!(event_parsed.locations.is_some());
assert_eq!(
event_parsed.locations.as_ref().unwrap()[0].label,
"Conference Room A"
);
}
}