Skip to main content

max / goingson

5.4 KB · 180 lines History Blame Raw
1 //! ICS/iCalendar export utilities for events.
2 //!
3 //! Generates RFC 5545 iCalendar files that can be imported into
4 //! Apple Calendar, Google Calendar, Outlook, etc.
5
6 use std::io::Write;
7
8 use chrono::{DateTime, Utc};
9 use goingson_core::Event;
10 use icalendar::{Calendar, Component, EventLike};
11
12 /// Writes events to ICS (iCalendar) format.
13 ///
14 /// The output follows RFC 5545 and is compatible with major calendar applications.
15 ///
16 /// # Arguments
17 ///
18 /// * `events` - Events to export
19 /// * `include_past` - If false, skips events that ended before now
20 /// * `writer` - Output destination
21 pub fn write_events_ics<W: Write>(
22 events: &[Event],
23 include_past: bool,
24 mut writer: W,
25 ) -> Result<usize, std::io::Error> {
26 let now = Utc::now();
27
28 let mut calendar = Calendar::new()
29 .name("GoingsOn Events")
30 .done();
31
32 let mut count = 0;
33
34 for event in events {
35 // Skip past events if requested
36 if !include_past {
37 let event_end = event.end_time.unwrap_or(event.start_time);
38 if event_end < now {
39 continue;
40 }
41 }
42
43 let mut ics_event = icalendar::Event::new();
44
45 // Required fields
46 ics_event.uid(&format!("{}@goingson", event.id));
47 ics_event.summary(&event.title);
48 ics_event.timestamp(event.start_time);
49
50 // Start and end times
51 set_event_times(&mut ics_event, event.start_time, event.end_time);
52
53 // Optional fields
54 if !event.description.is_empty() {
55 ics_event.description(&event.description);
56 }
57
58 if let Some(ref location) = event.location {
59 ics_event.location(location);
60 }
61
62 // Add recurrence rule if applicable
63 if let Some(rrule) = recurrence_to_rrule(&event.recurrence) {
64 ics_event.add_property("RRULE", &rrule);
65 }
66
67 calendar.push(ics_event.done());
68 count += 1;
69 }
70
71 write!(writer, "{}", calendar)?;
72 Ok(count)
73 }
74
75 /// Sets DTSTART and DTEND on an iCalendar event.
76 fn set_event_times(
77 ics_event: &mut icalendar::Event,
78 start: DateTime<Utc>,
79 end: Option<DateTime<Utc>>,
80 ) {
81 ics_event.starts(start);
82
83 if let Some(end_time) = end {
84 ics_event.ends(end_time);
85 } else {
86 // If no end time, assume 1 hour duration
87 ics_event.ends(start + chrono::Duration::hours(1));
88 }
89 }
90
91 /// Converts GoingsOn recurrence to iCalendar RRULE string.
92 fn recurrence_to_rrule(recurrence: &goingson_core::Recurrence) -> Option<String> {
93 match recurrence {
94 goingson_core::Recurrence::None => None,
95 goingson_core::Recurrence::Daily => Some("FREQ=DAILY".to_string()),
96 goingson_core::Recurrence::Weekly => Some("FREQ=WEEKLY".to_string()),
97 goingson_core::Recurrence::Monthly => Some("FREQ=MONTHLY".to_string()),
98 }
99 }
100
101 #[cfg(test)]
102 mod tests {
103 use super::*;
104 use goingson_core::{EventId, Recurrence};
105
106 fn make_event(title: &str, hours_from_now: i64) -> Event {
107 let start = Utc::now() + chrono::Duration::hours(hours_from_now);
108 Event {
109 id: EventId::new(),
110 user_id: None,
111 project_id: None,
112 project_name: None,
113 title: title.to_string(),
114 description: String::new(),
115 start_time: start,
116 end_time: Some(start + chrono::Duration::hours(1)),
117 location: None,
118 linked_task_id: None,
119 recurrence: Recurrence::None,
120 recurrence_rule: None,
121 is_recurring_instance: false,
122 recurrence_parent_id: None,
123 contact_id: None,
124 contact_name: None,
125 block_type: None,
126 external_source: None,
127 external_id: None,
128 is_read_only: false,
129 snoozed_until: None,
130 reminder_offsets_seconds: Vec::new(),
131 }
132 }
133
134 #[test]
135 fn test_write_empty_events() {
136 let mut buffer = Vec::new();
137 let count = write_events_ics(&[], true, &mut buffer).unwrap();
138 assert_eq!(count, 0);
139
140 let output = String::from_utf8(buffer).unwrap();
141 assert!(output.contains("BEGIN:VCALENDAR"));
142 assert!(output.contains("END:VCALENDAR"));
143 }
144
145 #[test]
146 fn test_write_future_event() {
147 let event = make_event("Future Meeting", 24);
148 let mut buffer = Vec::new();
149 let count = write_events_ics(&[event], false, &mut buffer).unwrap();
150 assert_eq!(count, 1);
151
152 let output = String::from_utf8(buffer).unwrap();
153 assert!(output.contains("Future Meeting"));
154 }
155
156 #[test]
157 fn test_skip_past_events() {
158 let event = make_event("Past Meeting", -24);
159 let mut buffer = Vec::new();
160 let count = write_events_ics(&[event], false, &mut buffer).unwrap();
161 assert_eq!(count, 0);
162 }
163
164 #[test]
165 fn test_include_past_events() {
166 let event = make_event("Past Meeting", -24);
167 let mut buffer = Vec::new();
168 let count = write_events_ics(&[event], true, &mut buffer).unwrap();
169 assert_eq!(count, 1);
170 }
171
172 #[test]
173 fn test_recurrence_to_rrule() {
174 assert_eq!(recurrence_to_rrule(&Recurrence::None), None);
175 assert_eq!(recurrence_to_rrule(&Recurrence::Daily), Some("FREQ=DAILY".to_string()));
176 assert_eq!(recurrence_to_rrule(&Recurrence::Weekly), Some("FREQ=WEEKLY".to_string()));
177 assert_eq!(recurrence_to_rrule(&Recurrence::Monthly), Some("FREQ=MONTHLY".to_string()));
178 }
179 }
180