The smokesignal.events web application
at main 130 lines 3.7 kB view raw
1use anyhow::Result; 2use chrono::{DateTime, Utc}; 3use icalendar::{Calendar, Component, Event as ICalEvent, EventLike}; 4 5/// Generate an iCalendar (.ics) file content for an event 6/// 7/// This is a shared helper used for both: 8/// - Email attachments (event summary notifications) 9/// - HTTP ICS export endpoints 10/// 11/// # Arguments 12/// * `uid` - Unique identifier for the event (e.g., event ATURI or generated hash) 13/// * `name` - Event title/summary 14/// * `description` - Optional event description 15/// * `location` - Optional event location 16/// * `starts_at` - Event start time (UTC) 17/// * `ends_at` - Optional event end time (UTC) 18/// * `url` - Optional URL to the event page 19pub fn generate_event_ics( 20 uid: &str, 21 name: &str, 22 description: Option<&str>, 23 location: Option<&str>, 24 starts_at: DateTime<Utc>, 25 ends_at: Option<DateTime<Utc>>, 26 url: Option<&str>, 27) -> Result<String> { 28 let mut calendar = Calendar::new(); 29 let mut ical_event = ICalEvent::new(); 30 31 // Set required fields 32 ical_event.uid(uid); 33 ical_event.summary(name); 34 ical_event.starts(starts_at); 35 36 // Set optional fields 37 if let Some(desc) = description { 38 ical_event.description(desc); 39 } 40 41 if let Some(loc) = location { 42 ical_event.location(loc); 43 } 44 45 if let Some(end) = ends_at { 46 ical_event.ends(end); 47 } 48 49 if let Some(event_url) = url { 50 ical_event.url(event_url); 51 } 52 53 // Add event to calendar 54 calendar.push(ical_event); 55 56 Ok(calendar.to_string()) 57} 58 59#[cfg(test)] 60mod tests { 61 use super::*; 62 use chrono::TimeZone; 63 64 #[test] 65 fn test_generate_basic_event() { 66 let start = Utc.with_ymd_and_hms(2025, 1, 15, 15, 0, 0).unwrap(); 67 68 let ics = generate_event_ics( 69 "event123@smokesignal.events", 70 "Test Event", 71 None, 72 None, 73 start, 74 None, 75 None, 76 ) 77 .unwrap(); 78 79 assert!(ics.contains("BEGIN:VCALENDAR")); 80 assert!(ics.contains("BEGIN:VEVENT")); 81 assert!(ics.contains("UID:event123@smokesignal.events")); 82 assert!(ics.contains("SUMMARY:Test Event")); 83 assert!(ics.contains("DTSTART")); 84 assert!(ics.contains("END:VEVENT")); 85 assert!(ics.contains("END:VCALENDAR")); 86 } 87 88 #[test] 89 fn test_generate_full_event() { 90 let start = Utc.with_ymd_and_hms(2025, 1, 15, 15, 0, 0).unwrap(); 91 let end = Utc.with_ymd_and_hms(2025, 1, 15, 17, 0, 0).unwrap(); 92 93 let ics = generate_event_ics( 94 "event456@smokesignal.events", 95 "Community Meetup", 96 Some("Join us for a community meetup!"), 97 Some("123 Main St, City"), 98 start, 99 Some(end), 100 Some("https://smokesignal.events/events/456"), 101 ) 102 .unwrap(); 103 104 assert!(ics.contains("SUMMARY:Community Meetup")); 105 assert!(ics.contains("DESCRIPTION:Join us for a community meetup!")); 106 assert!(ics.contains("LOCATION:123 Main St")); 107 assert!(ics.contains("DTEND")); 108 assert!(ics.contains("URL:https://smokesignal.events/events/456")); 109 } 110 111 #[test] 112 fn test_special_characters_are_escaped() { 113 let start = Utc.with_ymd_and_hms(2025, 1, 15, 15, 0, 0).unwrap(); 114 115 let ics = generate_event_ics( 116 "test", 117 "Event: Special; Chars,", 118 Some("Description\nwith newlines"), 119 Some("Location, with comma"), 120 start, 121 None, 122 None, 123 ) 124 .unwrap(); 125 126 // The icalendar crate should handle escaping automatically 127 assert!(ics.contains("BEGIN:VEVENT")); 128 assert!(ics.contains("END:VEVENT")); 129 } 130}