Skip to main content

max / goingson

13.0 KB · 448 lines History Blame Raw
1 //! iCalendar (.ics) parser for event import.
2 //!
3 //! Uses the `ical` crate for robust VEVENT parsing, then maps properties
4 //! to GO's Event model.
5
6 use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
7 use chrono_tz::Tz;
8 use goingson_core::Recurrence;
9 use ical::parser::ical::component::IcalEvent;
10 use ical::property::Property;
11 use serde::Serialize;
12
13 /// A fully parsed iCalendar event.
14 #[derive(Debug, Clone, Serialize)]
15 #[serde(rename_all = "camelCase")]
16 pub struct ParsedEvent {
17 pub title: String,
18 pub description: String,
19 pub start_time: DateTime<Utc>,
20 pub end_time: Option<DateTime<Utc>>,
21 pub location: Option<String>,
22 pub recurrence: Recurrence,
23 pub external_id: Option<String>,
24 }
25
26 /// Parse an .ics file content into a list of events.
27 pub fn parse_ics(content: &str) -> Result<Vec<ParsedEvent>, String> {
28 let reader = ical::IcalParser::new(content.as_bytes());
29 let mut events = Vec::new();
30
31 for calendar_result in reader {
32 let calendar = calendar_result.map_err(|e| format!("Failed to parse iCalendar: {}", e))?;
33
34 for ical_event in calendar.events {
35 if let Some(parsed) = parse_vevent(&ical_event) {
36 events.push(parsed);
37 }
38 }
39 }
40
41 Ok(events)
42 }
43
44 /// Parse a single VEVENT component.
45 fn parse_vevent(event: &IcalEvent) -> Option<ParsedEvent> {
46 let title = get_property_value(&event.properties, "SUMMARY")
47 .unwrap_or_default();
48
49 if title.is_empty() {
50 return None;
51 }
52
53 let description = get_property_value(&event.properties, "DESCRIPTION")
54 .unwrap_or_default();
55 let location = get_property_value(&event.properties, "LOCATION");
56 let uid = get_property_value(&event.properties, "UID");
57
58 // Parse start time
59 let start_time = parse_datetime_property(&event.properties, "DTSTART")?;
60
61 // Parse end time: DTEND takes precedence, then compute from DURATION
62 let end_time = parse_datetime_property(&event.properties, "DTEND")
63 .or_else(|| {
64 get_property_value(&event.properties, "DURATION")
65 .and_then(|d| parse_duration(&d))
66 .map(|dur| start_time + dur)
67 });
68
69 // Parse recurrence
70 let recurrence = get_property_value(&event.properties, "RRULE")
71 .map(|rule| parse_rrule(&rule))
72 .unwrap_or(Recurrence::None);
73
74 Some(ParsedEvent {
75 title,
76 description,
77 start_time,
78 end_time,
79 location,
80 recurrence,
81 external_id: uid,
82 })
83 }
84
85 /// Get the value of a named property.
86 fn get_property_value(properties: &[Property], name: &str) -> Option<String> {
87 properties
88 .iter()
89 .find(|p| p.name == name)
90 .and_then(|p| p.value.clone())
91 .map(|v| unescape_ical(&v))
92 }
93
94 /// Find a named property (for accessing params).
95 fn find_property<'a>(properties: &'a [Property], name: &str) -> Option<&'a Property> {
96 properties.iter().find(|p| p.name == name)
97 }
98
99 /// Parse a DTSTART or DTEND property, handling TZID, DATE, and DATE-TIME formats.
100 fn parse_datetime_property(properties: &[Property], name: &str) -> Option<DateTime<Utc>> {
101 let prop = find_property(properties, name)?;
102 let value = prop.value.as_deref()?;
103
104 // Check for VALUE=DATE (all-day event)
105 let is_date_only = prop.params.as_ref().is_some_and(|params| {
106 params.iter().any(|(k, v)| k == "VALUE" && v.iter().any(|val| val == "DATE"))
107 });
108
109 if is_date_only || (value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())) {
110 // All-day: YYYYMMDD → midnight UTC
111 return parse_date_to_utc(value);
112 }
113
114 // Check for TZID parameter
115 let tzid = prop.params.as_ref().and_then(|params| {
116 params
117 .iter()
118 .find(|(k, _)| k == "TZID")
119 .and_then(|(_, v)| v.first())
120 .map(|s| s.as_str())
121 });
122
123 // Try parsing with timezone
124 if let Some(tz_name) = tzid {
125 if let Some(ndt) = parse_ical_datetime(value) {
126 // Resolve IANA timezone and convert local time to UTC
127 if let Ok(tz) = tz_name.parse::<Tz>() {
128 // .earliest() returns None for times in a DST spring-forward gap.
129 // Fall back to .latest() which maps gap times to post-transition.
130 if let Some(local_dt) = tz
131 .from_local_datetime(&ndt)
132 .earliest()
133 .or_else(|| tz.from_local_datetime(&ndt).latest())
134 {
135 return Some(local_dt.with_timezone(&Utc));
136 }
137 }
138 // Fall back to treating as UTC if timezone can't be resolved
139 return Some(Utc.from_utc_datetime(&ndt));
140 }
141 }
142
143 // UTC (ends with Z)
144 if let Some(clean) = value.strip_suffix('Z') {
145 return parse_ical_datetime(clean).map(|ndt| Utc.from_utc_datetime(&ndt));
146 }
147
148 // Floating time (no timezone) → treat as UTC
149 parse_ical_datetime(value).map(|ndt| Utc.from_utc_datetime(&ndt))
150 }
151
152 /// Parse an iCalendar datetime string (YYYYMMDDTHHMMSS).
153 fn parse_ical_datetime(s: &str) -> Option<NaiveDateTime> {
154 // Format: 20260415T100000 — all ASCII, so validate before slicing
155 if s.len() < 15 || !s.is_ascii() {
156 return None;
157 }
158 let t_pos = s.find('T')?;
159 if t_pos != 8 {
160 return None;
161 }
162 let date_part = &s[..8];
163 let time_part = s.get(9..15)?;
164 let date = NaiveDate::parse_from_str(date_part, "%Y%m%d").ok()?;
165 let hour: u32 = time_part.get(0..2)?.parse().ok()?;
166 let min: u32 = time_part.get(2..4)?.parse().ok()?;
167 let sec: u32 = time_part.get(4..6)?.parse().ok()?;
168 Some(date.and_hms_opt(hour, min, sec)?)
169 }
170
171 /// Parse a DATE-only value to midnight UTC.
172 fn parse_date_to_utc(s: &str) -> Option<DateTime<Utc>> {
173 // Format: YYYYMMDD or YYYY-MM-DD
174 let clean = s.replace('-', "");
175 if clean.len() == 8 {
176 let date = NaiveDate::parse_from_str(&clean, "%Y%m%d").ok()?;
177 Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?))
178 } else {
179 None
180 }
181 }
182
183 /// Parse an iCalendar DURATION value (e.g., "PT1H30M", "P1D").
184 fn parse_duration(s: &str) -> Option<chrono::Duration> {
185 let s = s.trim();
186 if !s.starts_with('P') {
187 return None;
188 }
189 let s = &s[1..];
190
191 let mut total_seconds: i64 = 0;
192 let mut in_time = false;
193 let mut num_buf = String::new();
194
195 for ch in s.chars() {
196 match ch {
197 'T' => in_time = true,
198 '0'..='9' => num_buf.push(ch),
199 'D' if !in_time => {
200 total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 86400;
201 num_buf.clear();
202 }
203 'W' if !in_time => {
204 total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 604800;
205 num_buf.clear();
206 }
207 'H' if in_time => {
208 total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 3600;
209 num_buf.clear();
210 }
211 'M' if in_time => {
212 total_seconds += num_buf.parse::<i64>().unwrap_or(0) * 60;
213 num_buf.clear();
214 }
215 'S' if in_time => {
216 total_seconds += num_buf.parse::<i64>().unwrap_or(0);
217 num_buf.clear();
218 }
219 _ => {}
220 }
221 }
222
223 Some(chrono::Duration::seconds(total_seconds))
224 }
225
226 /// Parse an RRULE into a GO Recurrence. Only simple rules are mapped;
227 /// complex rules (BYDAY with multiple days, INTERVAL>1, UNTIL, COUNT) → None.
228 fn parse_rrule(rule: &str) -> Recurrence {
229 let parts: std::collections::HashMap<&str, &str> = rule
230 .split(';')
231 .filter_map(|part| {
232 let mut kv = part.splitn(2, '=');
233 Some((kv.next()?, kv.next()?))
234 })
235 .collect();
236
237 let freq = match parts.get("FREQ") {
238 Some(f) => f.to_uppercase(),
239 None => return Recurrence::None,
240 };
241
242 // Only map simple rules (INTERVAL=1 or absent)
243 let interval: u32 = parts
244 .get("INTERVAL")
245 .and_then(|v| v.parse().ok())
246 .unwrap_or(1);
247
248 if interval != 1 {
249 return Recurrence::None;
250 }
251
252 // Complex rules with BYDAY having multiple days → skip
253 if let Some(byday) = parts.get("BYDAY") {
254 if byday.contains(',') {
255 return Recurrence::None;
256 }
257 }
258
259 // UNTIL or COUNT → still map the frequency (they just limit recurrence)
260 match freq.as_str() {
261 "DAILY" => Recurrence::Daily,
262 "WEEKLY" => Recurrence::Weekly,
263 "MONTHLY" => Recurrence::Monthly,
264 _ => Recurrence::None,
265 }
266 }
267
268 /// Unescape iCalendar text values.
269 fn unescape_ical(s: &str) -> String {
270 s.replace("\\n", "\n")
271 .replace("\\N", "\n")
272 .replace("\\,", ",")
273 .replace("\\;", ";")
274 .replace("\\\\", "\\")
275 }
276
277 #[cfg(test)]
278 mod tests {
279 use super::*;
280
281 #[test]
282 fn test_parse_simple_event() {
283 let ics = "\
284 BEGIN:VCALENDAR\r\n\
285 VERSION:2.0\r\n\
286 BEGIN:VEVENT\r\n\
287 UID:test-uid-123@example.com\r\n\
288 SUMMARY:Team Meeting\r\n\
289 DTSTART:20260415T100000Z\r\n\
290 DTEND:20260415T110000Z\r\n\
291 LOCATION:Conference Room A\r\n\
292 DESCRIPTION:Weekly standup\r\n\
293 END:VEVENT\r\n\
294 END:VCALENDAR\r\n";
295
296 let events = parse_ics(ics).unwrap();
297 assert_eq!(events.len(), 1);
298
299 let e = &events[0];
300 assert_eq!(e.title, "Team Meeting");
301 assert_eq!(e.description, "Weekly standup");
302 assert_eq!(e.location.as_deref(), Some("Conference Room A"));
303 assert_eq!(e.external_id.as_deref(), Some("test-uid-123@example.com"));
304 assert!(e.end_time.is_some());
305 assert_eq!(e.recurrence, Recurrence::None);
306 }
307
308 #[test]
309 fn test_parse_all_day_event() {
310 let ics = "\
311 BEGIN:VCALENDAR\r\n\
312 VERSION:2.0\r\n\
313 BEGIN:VEVENT\r\n\
314 SUMMARY:Holiday\r\n\
315 DTSTART;VALUE=DATE:20260501\r\n\
316 DTEND;VALUE=DATE:20260502\r\n\
317 END:VEVENT\r\n\
318 END:VCALENDAR\r\n";
319
320 let events = parse_ics(ics).unwrap();
321 assert_eq!(events.len(), 1);
322 assert_eq!(events[0].title, "Holiday");
323 // All-day events start at midnight UTC
324 assert_eq!(events[0].start_time.hour(), 0);
325 }
326
327 #[test]
328 fn test_parse_recurring_daily() {
329 let ics = "\
330 BEGIN:VCALENDAR\r\n\
331 VERSION:2.0\r\n\
332 BEGIN:VEVENT\r\n\
333 SUMMARY:Daily Standup\r\n\
334 DTSTART:20260415T090000Z\r\n\
335 RRULE:FREQ=DAILY;INTERVAL=1\r\n\
336 END:VEVENT\r\n\
337 END:VCALENDAR\r\n";
338
339 let events = parse_ics(ics).unwrap();
340 assert_eq!(events[0].recurrence, Recurrence::Daily);
341 }
342
343 #[test]
344 fn test_parse_recurring_weekly() {
345 let ics = "\
346 BEGIN:VCALENDAR\r\n\
347 VERSION:2.0\r\n\
348 BEGIN:VEVENT\r\n\
349 SUMMARY:Weekly Review\r\n\
350 DTSTART:20260415T140000Z\r\n\
351 RRULE:FREQ=WEEKLY\r\n\
352 END:VEVENT\r\n\
353 END:VCALENDAR\r\n";
354
355 let events = parse_ics(ics).unwrap();
356 assert_eq!(events[0].recurrence, Recurrence::Weekly);
357 }
358
359 #[test]
360 fn test_complex_rrule_falls_back_to_none() {
361 let ics = "\
362 BEGIN:VCALENDAR\r\n\
363 VERSION:2.0\r\n\
364 BEGIN:VEVENT\r\n\
365 SUMMARY:MWF Meeting\r\n\
366 DTSTART:20260415T100000Z\r\n\
367 RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\
368 END:VEVENT\r\n\
369 END:VCALENDAR\r\n";
370
371 let events = parse_ics(ics).unwrap();
372 assert_eq!(events[0].recurrence, Recurrence::None);
373 }
374
375 #[test]
376 fn test_duration_instead_of_dtend() {
377 let ics = "\
378 BEGIN:VCALENDAR\r\n\
379 VERSION:2.0\r\n\
380 BEGIN:VEVENT\r\n\
381 SUMMARY:Quick Chat\r\n\
382 DTSTART:20260415T100000Z\r\n\
383 DURATION:PT30M\r\n\
384 END:VEVENT\r\n\
385 END:VCALENDAR\r\n";
386
387 let events = parse_ics(ics).unwrap();
388 let e = &events[0];
389 assert!(e.end_time.is_some());
390 let duration = e.end_time.unwrap() - e.start_time;
391 assert_eq!(duration.num_minutes(), 30);
392 }
393
394 #[test]
395 fn test_skip_event_without_summary() {
396 let ics = "\
397 BEGIN:VCALENDAR\r\n\
398 VERSION:2.0\r\n\
399 BEGIN:VEVENT\r\n\
400 DTSTART:20260415T100000Z\r\n\
401 END:VEVENT\r\n\
402 END:VCALENDAR\r\n";
403
404 let events = parse_ics(ics).unwrap();
405 assert_eq!(events.len(), 0);
406 }
407
408 #[test]
409 fn test_multiple_events() {
410 let ics = "\
411 BEGIN:VCALENDAR\r\n\
412 VERSION:2.0\r\n\
413 BEGIN:VEVENT\r\n\
414 SUMMARY:Event 1\r\n\
415 DTSTART:20260415T100000Z\r\n\
416 END:VEVENT\r\n\
417 BEGIN:VEVENT\r\n\
418 SUMMARY:Event 2\r\n\
419 DTSTART:20260416T140000Z\r\n\
420 END:VEVENT\r\n\
421 END:VCALENDAR\r\n";
422
423 let events = parse_ics(ics).unwrap();
424 assert_eq!(events.len(), 2);
425 assert_eq!(events[0].title, "Event 1");
426 assert_eq!(events[1].title, "Event 2");
427 }
428
429 #[test]
430 fn test_parse_duration_values() {
431 assert_eq!(parse_duration("PT1H").unwrap().num_seconds(), 3600);
432 assert_eq!(parse_duration("PT30M").unwrap().num_seconds(), 1800);
433 assert_eq!(parse_duration("PT1H30M").unwrap().num_seconds(), 5400);
434 assert_eq!(parse_duration("P1D").unwrap().num_seconds(), 86400);
435 assert_eq!(parse_duration("P1W").unwrap().num_seconds(), 604800);
436 assert_eq!(parse_duration("P1DT2H30M").unwrap().num_seconds(), 86400 + 7200 + 1800);
437 }
438
439 #[test]
440 fn test_unescape_ical() {
441 assert_eq!(unescape_ical("Hello\\nWorld"), "Hello\nWorld");
442 assert_eq!(unescape_ical("A\\,B\\;C"), "A,B;C");
443 }
444 }
445
446 #[cfg(test)]
447 use chrono::Timelike;
448