//! ICS/iCalendar export utilities for events. //! //! Generates RFC 5545 iCalendar files that can be imported into //! Apple Calendar, Google Calendar, Outlook, etc. use std::io::Write; use chrono::{DateTime, Utc}; use goingson_core::Event; use icalendar::{Calendar, Component, EventLike}; /// Writes events to ICS (iCalendar) format. /// /// The output follows RFC 5545 and is compatible with major calendar applications. /// /// # Arguments /// /// * `events` - Events to export /// * `include_past` - If false, skips events that ended before now /// * `writer` - Output destination pub fn write_events_ics( events: &[Event], include_past: bool, mut writer: W, ) -> Result { let now = Utc::now(); let mut calendar = Calendar::new() .name("GoingsOn Events") .done(); let mut count = 0; for event in events { // Skip past events if requested if !include_past { let event_end = event.end_time.unwrap_or(event.start_time); if event_end < now { continue; } } let mut ics_event = icalendar::Event::new(); // Required fields ics_event.uid(&format!("{}@goingson", event.id)); ics_event.summary(&event.title); ics_event.timestamp(event.start_time); // Start and end times set_event_times(&mut ics_event, event.start_time, event.end_time); // Optional fields if !event.description.is_empty() { ics_event.description(&event.description); } if let Some(ref location) = event.location { ics_event.location(location); } // Add recurrence rule if applicable if let Some(rrule) = recurrence_to_rrule(&event.recurrence) { ics_event.add_property("RRULE", &rrule); } calendar.push(ics_event.done()); count += 1; } write!(writer, "{}", calendar)?; Ok(count) } /// Sets DTSTART and DTEND on an iCalendar event. fn set_event_times( ics_event: &mut icalendar::Event, start: DateTime, end: Option>, ) { ics_event.starts(start); if let Some(end_time) = end { ics_event.ends(end_time); } else { // If no end time, assume 1 hour duration ics_event.ends(start + chrono::Duration::hours(1)); } } /// Converts GoingsOn recurrence to iCalendar RRULE string. fn recurrence_to_rrule(recurrence: &goingson_core::Recurrence) -> Option { match recurrence { goingson_core::Recurrence::None => None, goingson_core::Recurrence::Daily => Some("FREQ=DAILY".to_string()), goingson_core::Recurrence::Weekly => Some("FREQ=WEEKLY".to_string()), goingson_core::Recurrence::Monthly => Some("FREQ=MONTHLY".to_string()), } } #[cfg(test)] mod tests { use super::*; use goingson_core::{EventId, Recurrence}; fn make_event(title: &str, hours_from_now: i64) -> Event { let start = Utc::now() + chrono::Duration::hours(hours_from_now); Event { id: EventId::new(), user_id: None, project_id: None, project_name: None, title: title.to_string(), description: String::new(), start_time: start, end_time: Some(start + chrono::Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, is_recurring_instance: false, recurrence_parent_id: None, contact_id: None, contact_name: None, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), } } #[test] fn test_write_empty_events() { let mut buffer = Vec::new(); let count = write_events_ics(&[], true, &mut buffer).unwrap(); assert_eq!(count, 0); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("BEGIN:VCALENDAR")); assert!(output.contains("END:VCALENDAR")); } #[test] fn test_write_future_event() { let event = make_event("Future Meeting", 24); let mut buffer = Vec::new(); let count = write_events_ics(&[event], false, &mut buffer).unwrap(); assert_eq!(count, 1); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("Future Meeting")); } #[test] fn test_skip_past_events() { let event = make_event("Past Meeting", -24); let mut buffer = Vec::new(); let count = write_events_ics(&[event], false, &mut buffer).unwrap(); assert_eq!(count, 0); } #[test] fn test_include_past_events() { let event = make_event("Past Meeting", -24); let mut buffer = Vec::new(); let count = write_events_ics(&[event], true, &mut buffer).unwrap(); assert_eq!(count, 1); } #[test] fn test_recurrence_to_rrule() { assert_eq!(recurrence_to_rrule(&Recurrence::None), None); assert_eq!(recurrence_to_rrule(&Recurrence::Daily), Some("FREQ=DAILY".to_string())); assert_eq!(recurrence_to_rrule(&Recurrence::Weekly), Some("FREQ=WEEKLY".to_string())); assert_eq!(recurrence_to_rrule(&Recurrence::Monthly), Some("FREQ=MONTHLY".to_string())); } }