Skip to main content

max / goingson

15.5 KB · 486 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, Local, 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 // `start_time + dur` panics in chrono on overflow, so add it checked: a
63 // duration that would push the end time out of range yields no end time
64 // rather than crashing the whole import.
65 let end_time = parse_datetime_property(&event.properties, "DTEND")
66 .or_else(|| {
67 get_property_value(&event.properties, "DURATION")
68 .and_then(|d| parse_duration(&d))
69 .and_then(|dur| start_time.checked_add_signed(dur))
70 });
71
72 // Parse recurrence
73 let recurrence = get_property_value(&event.properties, "RRULE")
74 .map(|rule| parse_rrule(&rule))
75 .unwrap_or(Recurrence::None);
76
77 Some(ParsedEvent {
78 title,
79 description,
80 start_time,
81 end_time,
82 location,
83 recurrence,
84 external_id: uid,
85 })
86 }
87
88 /// Get the value of a named property.
89 fn get_property_value(properties: &[Property], name: &str) -> Option<String> {
90 properties
91 .iter()
92 .find(|p| p.name == name)
93 .and_then(|p| p.value.clone())
94 .map(|v| unescape_ical(&v))
95 }
96
97 /// Find a named property (for accessing params).
98 fn find_property<'a>(properties: &'a [Property], name: &str) -> Option<&'a Property> {
99 properties.iter().find(|p| p.name == name)
100 }
101
102 /// Parse a DTSTART or DTEND property, handling TZID, DATE, and DATE-TIME formats.
103 fn parse_datetime_property(properties: &[Property], name: &str) -> Option<DateTime<Utc>> {
104 let prop = find_property(properties, name)?;
105 let value = prop.value.as_deref()?;
106
107 // Check for VALUE=DATE (all-day event)
108 let is_date_only = prop.params.as_ref().is_some_and(|params| {
109 params.iter().any(|(k, v)| k == "VALUE" && v.iter().any(|val| val == "DATE"))
110 });
111
112 if is_date_only || (value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())) {
113 // All-day: YYYYMMDD → midnight UTC
114 return parse_date_to_utc(value);
115 }
116
117 // Check for TZID parameter
118 let tzid = prop.params.as_ref().and_then(|params| {
119 params
120 .iter()
121 .find(|(k, _)| k == "TZID")
122 .and_then(|(_, v)| v.first())
123 .map(|s| s.as_str())
124 });
125
126 // Try parsing with timezone
127 if let Some(tz_name) = tzid
128 && let Some(ndt) = parse_ical_datetime(value) {
129 // Resolve IANA timezone and convert local time to UTC
130 if let Ok(tz) = tz_name.parse::<Tz>() {
131 // .earliest() returns None for times in a DST spring-forward gap.
132 // Fall back to .latest() which maps gap times to post-transition.
133 if let Some(local_dt) = tz
134 .from_local_datetime(&ndt)
135 .earliest()
136 .or_else(|| tz.from_local_datetime(&ndt).latest())
137 {
138 return Some(local_dt.with_timezone(&Utc));
139 }
140 }
141 // Timezone name couldn't be resolved: interpret the wall-clock time in
142 // the machine's local zone rather than UTC (closer to the author's
143 // intent than a blind UTC stamp).
144 return naive_local_to_utc(&ndt);
145 }
146
147 // UTC (ends with Z)
148 if let Some(clean) = value.strip_suffix('Z') {
149 return parse_ical_datetime(clean).map(|ndt| Utc.from_utc_datetime(&ndt));
150 }
151
152 // Floating time (no timezone) → interpret as local wall-clock per RFC 5545,
153 // not UTC (a blind UTC stamp shifts the event by the viewer's offset).
154 parse_ical_datetime(value).and_then(|ndt| naive_local_to_utc(&ndt))
155 }
156
157 /// Convert a naive (timezone-less) datetime to UTC by interpreting it in the
158 /// machine's local timezone -- the RFC 5545 meaning of a floating time. Handles
159 /// DST gaps by falling back from `.earliest()` to `.latest()`.
160 fn naive_local_to_utc(ndt: &NaiveDateTime) -> Option<DateTime<Utc>> {
161 Local
162 .from_local_datetime(ndt)
163 .earliest()
164 .or_else(|| Local.from_local_datetime(ndt).latest())
165 .map(|dt| dt.with_timezone(&Utc))
166 }
167
168 /// Parse an iCalendar datetime string (YYYYMMDDTHHMMSS).
169 fn parse_ical_datetime(s: &str) -> Option<NaiveDateTime> {
170 // Format: 20260415T100000 — all ASCII, so validate before slicing
171 if s.len() < 15 || !s.is_ascii() {
172 return None;
173 }
174 let t_pos = s.find('T')?;
175 if t_pos != 8 {
176 return None;
177 }
178 let date_part = &s[..8];
179 let time_part = s.get(9..15)?;
180 let date = NaiveDate::parse_from_str(date_part, "%Y%m%d").ok()?;
181 let hour: u32 = time_part.get(0..2)?.parse().ok()?;
182 let min: u32 = time_part.get(2..4)?.parse().ok()?;
183 let sec: u32 = time_part.get(4..6)?.parse().ok()?;
184 date.and_hms_opt(hour, min, sec)
185 }
186
187 /// Parse a DATE-only value to midnight UTC.
188 fn parse_date_to_utc(s: &str) -> Option<DateTime<Utc>> {
189 // Format: YYYYMMDD or YYYY-MM-DD
190 let clean = s.replace('-', "");
191 if clean.len() == 8 {
192 let date = NaiveDate::parse_from_str(&clean, "%Y%m%d").ok()?;
193 Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?))
194 } else {
195 None
196 }
197 }
198
199 /// Parse an iCalendar DURATION value (e.g., "PT1H30M", "P1D").
200 fn parse_duration(s: &str) -> Option<chrono::Duration> {
201 let s = s.trim();
202 if !s.starts_with('P') {
203 return None;
204 }
205 let s = &s[1..];
206
207 // A hostile or malformed .ics can specify enormous component values
208 // (e.g. "P9999999999999999999D"). Every step uses checked arithmetic so an
209 // overflow yields None rather than panicking in debug / wrapping in release;
210 // the event then simply falls back to no computed end time.
211 let mut total_seconds: i64 = 0;
212 let mut in_time = false;
213 let mut num_buf = String::new();
214
215 let mut add = |buf: &mut String, unit_secs: i64| -> Option<()> {
216 // An unparseable / overflowing number contributes 0, matching the
217 // previous lenient behaviour for non-overflow garbage.
218 let n = buf.parse::<i64>().unwrap_or(0);
219 buf.clear();
220 total_seconds = total_seconds.checked_add(n.checked_mul(unit_secs)?)?;
221 Some(())
222 };
223
224 for ch in s.chars() {
225 match ch {
226 'T' => in_time = true,
227 '0'..='9' => num_buf.push(ch),
228 'D' if !in_time => add(&mut num_buf, 86400)?,
229 'W' if !in_time => add(&mut num_buf, 604800)?,
230 'H' if in_time => add(&mut num_buf, 3600)?,
231 'M' if in_time => add(&mut num_buf, 60)?,
232 'S' if in_time => add(&mut num_buf, 1)?,
233 _ => {}
234 }
235 }
236
237 // `Duration::seconds` panics on values outside chrono's millisecond range;
238 // `try_seconds` returns None instead, so an absurd total degrades to "no
239 // duration" rather than crashing.
240 chrono::Duration::try_seconds(total_seconds)
241 }
242
243 /// Parse an RRULE into a GO Recurrence. Only simple rules are mapped;
244 /// complex rules (BYDAY with multiple days, INTERVAL>1, UNTIL, COUNT) → None.
245 fn parse_rrule(rule: &str) -> Recurrence {
246 let parts: std::collections::HashMap<&str, &str> = rule
247 .split(';')
248 .filter_map(|part| {
249 let mut kv = part.splitn(2, '=');
250 Some((kv.next()?, kv.next()?))
251 })
252 .collect();
253
254 let freq = match parts.get("FREQ") {
255 Some(f) => f.to_uppercase(),
256 None => return Recurrence::None,
257 };
258
259 // Only map simple rules (INTERVAL=1 or absent)
260 let interval: u32 = parts
261 .get("INTERVAL")
262 .and_then(|v| v.parse().ok())
263 .unwrap_or(1);
264
265 if interval != 1 {
266 return Recurrence::None;
267 }
268
269 // Complex rules with BYDAY having multiple days → skip
270 if let Some(byday) = parts.get("BYDAY")
271 && byday.contains(',') {
272 return Recurrence::None;
273 }
274
275 // UNTIL or COUNT → still map the frequency (they just limit recurrence)
276 match freq.as_str() {
277 "DAILY" => Recurrence::Daily,
278 "WEEKLY" => Recurrence::Weekly,
279 "MONTHLY" => Recurrence::Monthly,
280 _ => Recurrence::None,
281 }
282 }
283
284 /// Unescape iCalendar text values.
285 fn unescape_ical(s: &str) -> String {
286 s.replace("\\n", "\n")
287 .replace("\\N", "\n")
288 .replace("\\,", ",")
289 .replace("\\;", ";")
290 .replace("\\\\", "\\")
291 }
292
293 #[cfg(test)]
294 mod tests {
295 use super::*;
296 use chrono::Timelike;
297
298 #[test]
299 fn test_parse_simple_event() {
300 let ics = "\
301 BEGIN:VCALENDAR\r\n\
302 VERSION:2.0\r\n\
303 BEGIN:VEVENT\r\n\
304 UID:test-uid-123@example.com\r\n\
305 SUMMARY:Team Meeting\r\n\
306 DTSTART:20260415T100000Z\r\n\
307 DTEND:20260415T110000Z\r\n\
308 LOCATION:Conference Room A\r\n\
309 DESCRIPTION:Weekly standup\r\n\
310 END:VEVENT\r\n\
311 END:VCALENDAR\r\n";
312
313 let events = parse_ics(ics).unwrap();
314 assert_eq!(events.len(), 1);
315
316 let e = &events[0];
317 assert_eq!(e.title, "Team Meeting");
318 assert_eq!(e.description, "Weekly standup");
319 assert_eq!(e.location.as_deref(), Some("Conference Room A"));
320 assert_eq!(e.external_id.as_deref(), Some("test-uid-123@example.com"));
321 assert!(e.end_time.is_some());
322 assert_eq!(e.recurrence, Recurrence::None);
323 }
324
325 #[test]
326 fn test_parse_all_day_event() {
327 let ics = "\
328 BEGIN:VCALENDAR\r\n\
329 VERSION:2.0\r\n\
330 BEGIN:VEVENT\r\n\
331 SUMMARY:Holiday\r\n\
332 DTSTART;VALUE=DATE:20260501\r\n\
333 DTEND;VALUE=DATE:20260502\r\n\
334 END:VEVENT\r\n\
335 END:VCALENDAR\r\n";
336
337 let events = parse_ics(ics).unwrap();
338 assert_eq!(events.len(), 1);
339 assert_eq!(events[0].title, "Holiday");
340 // All-day events start at midnight UTC
341 assert_eq!(events[0].start_time.hour(), 0);
342 }
343
344 #[test]
345 fn test_parse_recurring_daily() {
346 let ics = "\
347 BEGIN:VCALENDAR\r\n\
348 VERSION:2.0\r\n\
349 BEGIN:VEVENT\r\n\
350 SUMMARY:Daily Standup\r\n\
351 DTSTART:20260415T090000Z\r\n\
352 RRULE:FREQ=DAILY;INTERVAL=1\r\n\
353 END:VEVENT\r\n\
354 END:VCALENDAR\r\n";
355
356 let events = parse_ics(ics).unwrap();
357 assert_eq!(events[0].recurrence, Recurrence::Daily);
358 }
359
360 #[test]
361 fn test_parse_recurring_weekly() {
362 let ics = "\
363 BEGIN:VCALENDAR\r\n\
364 VERSION:2.0\r\n\
365 BEGIN:VEVENT\r\n\
366 SUMMARY:Weekly Review\r\n\
367 DTSTART:20260415T140000Z\r\n\
368 RRULE:FREQ=WEEKLY\r\n\
369 END:VEVENT\r\n\
370 END:VCALENDAR\r\n";
371
372 let events = parse_ics(ics).unwrap();
373 assert_eq!(events[0].recurrence, Recurrence::Weekly);
374 }
375
376 #[test]
377 fn test_complex_rrule_falls_back_to_none() {
378 let ics = "\
379 BEGIN:VCALENDAR\r\n\
380 VERSION:2.0\r\n\
381 BEGIN:VEVENT\r\n\
382 SUMMARY:MWF Meeting\r\n\
383 DTSTART:20260415T100000Z\r\n\
384 RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\
385 END:VEVENT\r\n\
386 END:VCALENDAR\r\n";
387
388 let events = parse_ics(ics).unwrap();
389 assert_eq!(events[0].recurrence, Recurrence::None);
390 }
391
392 #[test]
393 fn test_duration_instead_of_dtend() {
394 let ics = "\
395 BEGIN:VCALENDAR\r\n\
396 VERSION:2.0\r\n\
397 BEGIN:VEVENT\r\n\
398 SUMMARY:Quick Chat\r\n\
399 DTSTART:20260415T100000Z\r\n\
400 DURATION:PT30M\r\n\
401 END:VEVENT\r\n\
402 END:VCALENDAR\r\n";
403
404 let events = parse_ics(ics).unwrap();
405 let e = &events[0];
406 assert!(e.end_time.is_some());
407 let duration = e.end_time.unwrap() - e.start_time;
408 assert_eq!(duration.num_minutes(), 30);
409 }
410
411 #[test]
412 fn test_skip_event_without_summary() {
413 let ics = "\
414 BEGIN:VCALENDAR\r\n\
415 VERSION:2.0\r\n\
416 BEGIN:VEVENT\r\n\
417 DTSTART:20260415T100000Z\r\n\
418 END:VEVENT\r\n\
419 END:VCALENDAR\r\n";
420
421 let events = parse_ics(ics).unwrap();
422 assert_eq!(events.len(), 0);
423 }
424
425 #[test]
426 fn test_multiple_events() {
427 let ics = "\
428 BEGIN:VCALENDAR\r\n\
429 VERSION:2.0\r\n\
430 BEGIN:VEVENT\r\n\
431 SUMMARY:Event 1\r\n\
432 DTSTART:20260415T100000Z\r\n\
433 END:VEVENT\r\n\
434 BEGIN:VEVENT\r\n\
435 SUMMARY:Event 2\r\n\
436 DTSTART:20260416T140000Z\r\n\
437 END:VEVENT\r\n\
438 END:VCALENDAR\r\n";
439
440 let events = parse_ics(ics).unwrap();
441 assert_eq!(events.len(), 2);
442 assert_eq!(events[0].title, "Event 1");
443 assert_eq!(events[1].title, "Event 2");
444 }
445
446 #[test]
447 fn test_parse_duration_values() {
448 assert_eq!(parse_duration("PT1H").unwrap().num_seconds(), 3600);
449 assert_eq!(parse_duration("PT30M").unwrap().num_seconds(), 1800);
450 assert_eq!(parse_duration("PT1H30M").unwrap().num_seconds(), 5400);
451 assert_eq!(parse_duration("P1D").unwrap().num_seconds(), 86400);
452 assert_eq!(parse_duration("P1W").unwrap().num_seconds(), 604800);
453 assert_eq!(parse_duration("P1DT2H30M").unwrap().num_seconds(), 86400 + 7200 + 1800);
454 }
455
456 #[test]
457 fn test_parse_duration_overflow_is_none_not_panic() {
458 // Values that parse as i64 but overflow when scaled to seconds, or that
459 // exceed chrono's Duration range, must yield None rather than panicking.
460 assert_eq!(parse_duration("P9223372036854775807D"), None); // i64::MAX days
461 assert_eq!(parse_duration("PT9223372036854775807H"), None); // i64::MAX hours
462 assert_eq!(parse_duration("P106751991167301D"), None); // overflows *86400
463 // Unparseable (too many digits for i64) degrades to zero, never panics.
464 assert_eq!(parse_duration("P9999999999999999999D").map(|d| d.num_seconds()), Some(0));
465 }
466
467 #[test]
468 fn test_event_with_overflowing_duration_does_not_panic() {
469 // A duration that fits chrono::Duration but pushes the end time past
470 // chrono's date range exercises the checked_add_signed guard at the
471 // caller: end_time stays unset rather than panicking the import.
472 let ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Overflow\r\n\
473 DTSTART:20260101T120000Z\r\nDURATION:P100000000000D\r\n\
474 END:VEVENT\r\nEND:VCALENDAR\r\n";
475 let events = parse_ics(ics).unwrap();
476 assert_eq!(events.len(), 1);
477 assert!(events[0].end_time.is_none());
478 }
479
480 #[test]
481 fn test_unescape_ical() {
482 assert_eq!(unescape_ical("Hello\\nWorld"), "Hello\nWorld");
483 assert_eq!(unescape_ical("A\\,B\\;C"), "A,B;C");
484 }
485 }
486