//! Turning Google's JSON events into iCalendar, and back. //! //! The shapes here were established by reading what the API actually returns, //! not from the reference alone. Three things drive the design: //! //! * A recurring event's exceptions carry the **same `iCalUID`** as their master, //! so a master and all its exceptions belong in one file — which is exactly the //! vdir convention of one file per UID. //! * `start.dateTime` is an absolute instant while `start.timeZone` names the zone //! the recurrence expands in, and **the two need not agree**: a real event here //! reads `2023-10-31T13:00:00+02:00` with `timeZone: Asia/Karachi`, which is //! +05:00. Emitting the instant under that TZID without converting it would move //! the event by three hours. //! * An exception that was deleted comes back as an override with //! `status: cancelled`, which is an `EXDATE` on the master rather than an event. use std::collections::BTreeMap; use jiff::{Timestamp, tz::TimeZone}; use serde::Deserialize; use thiserror::Error; use crate::mirror::escape_text; #[derive(Debug, Error)] pub enum ConvertError { #[error("event {id} has no iCalUID, so it cannot be stored")] NoUid { id: String }, #[error("event {id} has neither a start date nor a start time")] NoStart { id: String }, #[error("event {id} has an unparseable {field} `{value}`: {source}")] BadTime { id: String, field: &'static str, value: String, #[source] source: jiff::Error, }, } /// A Google calendar event, limited to the fields calcalist maps. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Event { pub id: String, #[serde(rename = "iCalUID")] pub ical_uid: Option, #[serde(default)] pub status: Option, #[serde(default)] pub summary: Option, #[serde(default)] pub description: Option, #[serde(default)] pub location: Option, #[serde(default)] pub start: Option, #[serde(default)] pub end: Option, /// Raw iCalendar recurrence lines — RRULE, EXDATE, RDATE — passed through. #[serde(default)] pub recurrence: Option>, #[serde(default)] pub recurring_event_id: Option, #[serde(default)] pub original_start_time: Option, #[serde(default)] pub transparency: Option, #[serde(default)] pub sequence: Option, #[serde(default)] pub updated: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EventTime { /// Set for all-day events, as `YYYY-MM-DD`. #[serde(default)] pub date: Option, /// An absolute instant, RFC 3339. #[serde(default)] pub date_time: Option, /// The zone the recurrence expands in — not necessarily the offset above. #[serde(default)] pub time_zone: Option, } impl Event { pub fn is_cancelled(&self) -> bool { self.status.as_deref() == Some("cancelled") } pub fn is_override(&self) -> bool { self.recurring_event_id.is_some() } fn is_recurring(&self) -> bool { self.recurrence .as_ref() .is_some_and(|lines| !lines.is_empty()) } } /// A master event together with its exceptions, all sharing one UID. #[derive(Debug, Default)] pub struct Group { pub master: Option, pub overrides: Vec, } /// Groups a page of events by the UID they will be stored under. pub fn group_by_uid(events: Vec) -> BTreeMap { let mut groups: BTreeMap = BTreeMap::new(); for event in events { let Some(uid) = event.ical_uid.clone() else { continue; }; let group = groups.entry(uid).or_default(); if event.is_override() { group.overrides.push(event); } else { group.master = Some(event); } } groups } /// Renders a group as one iCalendar object. pub fn to_ical(uid: &str, group: &Group) -> Result { let Some(master) = &group.master else { // Exceptions arrived without their master, which happens on an // incremental sync. The caller merges these into the stored file. return Err(ConvertError::NoUid { id: uid.to_string(), }); }; let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calcalist//EN\r\n"); out.push_str(&render_event(uid, master, None)?); // A deleted occurrence is an absence, not an event: it belongs on the master // as an EXDATE. for cancelled in group.overrides.iter().filter(|event| event.is_cancelled()) { if let Some(start) = &cancelled.original_start_time { let line = time_property("EXDATE", start, &cancelled.id, master.is_recurring())?; // Insert before the master's END:VEVENT. let end = out.rfind("END:VEVENT\r\n").unwrap_or(out.len()); out.insert_str(end, &format!("{line}\r\n")); } } for moved in group.overrides.iter().filter(|event| !event.is_cancelled()) { out.push_str(&render_event( uid, moved, moved.original_start_time.as_ref(), )?); } out.push_str("END:VCALENDAR\r\n"); Ok(out) } fn render_event( uid: &str, event: &Event, recurrence_id: Option<&EventTime>, ) -> Result { let recurring = event.is_recurring() || recurrence_id.is_some(); let mut out = String::from("BEGIN:VEVENT\r\n"); out.push_str(&format!("UID:{uid}\r\n")); out.push_str(&format!("DTSTAMP:{}\r\n", stamp(event)?)); if let Some(original) = recurrence_id { out.push_str(&time_property( "RECURRENCE-ID", original, &event.id, recurring, )?); out.push_str("\r\n"); } let start = event.start.as_ref().ok_or_else(|| ConvertError::NoStart { id: event.id.clone(), })?; out.push_str(&time_property("DTSTART", start, &event.id, recurring)?); out.push_str("\r\n"); if let Some(end) = &event.end { out.push_str(&time_property("DTEND", end, &event.id, recurring)?); out.push_str("\r\n"); } for line in event.recurrence.iter().flatten() { out.push_str(line); out.push_str("\r\n"); } for (name, value) in [ ("SUMMARY", event.summary.as_deref()), ("DESCRIPTION", event.description.as_deref()), ("LOCATION", event.location.as_deref()), ] { if let Some(value) = value { out.push_str(&format!("{name}:{}\r\n", escape_text(value))); } } if let Some(transparency) = &event.transparency { let value = if transparency == "transparent" { "TRANSPARENT" } else { "OPAQUE" }; out.push_str(&format!("TRANSP:{value}\r\n")); } if event.status.as_deref() == Some("tentative") { out.push_str("STATUS:TENTATIVE\r\n"); } if let Some(sequence) = event.sequence { out.push_str(&format!("SEQUENCE:{sequence}\r\n")); } out.push_str("END:VEVENT\r\n"); Ok(out) } /// Renders one date-or-time property. /// /// Timed values are written in UTC unless the event recurs. A recurrence has to /// expand in its own zone or it drifts by an hour across a daylight-saving /// change, so those carry a TZID — and the instant is converted into that zone /// first, since Google's offset and its `timeZone` may disagree. fn time_property( name: &str, time: &EventTime, id: &str, recurring: bool, ) -> Result { if let Some(date) = &time.date { return Ok(format!("{name};VALUE=DATE:{}", date.replace('-', ""))); } let value = time .date_time .as_deref() .ok_or_else(|| ConvertError::NoStart { id: id.to_string() })?; let instant: Timestamp = value.parse().map_err(|source| ConvertError::BadTime { id: id.to_string(), field: "dateTime", value: value.to_string(), source, })?; let zone = time .time_zone .as_deref() .filter(|_| recurring) .and_then(|name| TimeZone::get(name).ok().map(|zone| (name, zone))); match zone { Some((zone_name, zone)) => { let local = instant.to_zoned(zone); Ok(format!( "{name};TZID={zone_name}:{}", local.strftime("%Y%m%dT%H%M%S") )) } None => Ok(format!( "{name}:{}", instant.to_zoned(TimeZone::UTC).strftime("%Y%m%dT%H%M%SZ") )), } } /// DTSTAMP, taken from the event's last update where Google supplies one. fn stamp(event: &Event) -> Result { let Some(updated) = &event.updated else { return Ok(Timestamp::now().strftime("%Y%m%dT%H%M%SZ").to_string()); }; let instant: Timestamp = updated.parse().map_err(|source| ConvertError::BadTime { id: event.id.clone(), field: "updated", value: updated.clone(), source, })?; Ok(instant.strftime("%Y%m%dT%H%M%SZ").to_string()) } #[cfg(test)] mod tests { use super::*; use crate::ical::Calendar; fn parse(json: &str) -> Vec { serde_json::from_str(json).expect("fixture should deserialise") } /// Taken verbatim from a real calendar: the offset is +02:00 while the zone /// is Asia/Karachi, which is +05:00. const KARACHI_MASTER: &str = r#"[{ "id": "master1", "status": "confirmed", "summary": "Flux Dev Meeting", "start": {"dateTime": "2023-10-31T13:00:00+02:00", "timeZone": "Asia/Karachi"}, "end": {"dateTime": "2023-10-31T14:00:00+02:00", "timeZone": "Asia/Karachi"}, "recurrence": ["RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=1;BYDAY=TU"], "iCalUID": "master1@google.com", "sequence": 1, "updated": "2024-06-18T10:04:30.544Z" }]"#; /// The instant is 11:00Z; under Asia/Karachi that is 16:00, not the 13:00 /// the offset in the payload suggests. #[test] fn a_recurring_start_is_converted_into_its_own_zone() { let groups = group_by_uid(parse(KARACHI_MASTER)); let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert"); assert!( ics.contains("DTSTART;TZID=Asia/Karachi:20231031T160000"), "{ics}" ); assert!( ics.contains("DTEND;TZID=Asia/Karachi:20231031T170000"), "{ics}" ); } #[test] fn recurrence_lines_pass_straight_through() { let groups = group_by_uid(parse(KARACHI_MASTER)); let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert"); assert!(ics.contains("RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=1;BYDAY=TU")); } /// A one-off needs no zone to expand in, so UTC keeps it unambiguous and /// avoids referencing a VTIMEZONE that is not there. #[test] fn a_single_event_is_written_in_utc() { let json = r#"[{ "id": "one", "status": "confirmed", "summary": "Dentist", "start": {"dateTime": "2026-01-08T14:30:00+02:00", "timeZone": "Asia/Jerusalem"}, "end": {"dateTime": "2026-01-08T15:30:00+02:00", "timeZone": "Asia/Jerusalem"}, "iCalUID": "one@google.com", "updated": "2026-01-01T00:00:00.000Z" }]"#; let groups = group_by_uid(parse(json)); let ics = to_ical("one@google.com", &groups["one@google.com"]).expect("convert"); assert!(ics.contains("DTSTART:20260108T123000Z"), "{ics}"); assert!(!ics.contains("TZID"), "{ics}"); } #[test] fn an_all_day_event_keeps_its_date_form() { let json = r#"[{ "id": "allday", "status": "confirmed", "summary": "Holiday", "start": {"date": "2024-04-08"}, "end": {"date": "2024-04-09"}, "iCalUID": "allday@google.com", "updated": "2024-01-01T00:00:00.000Z" }]"#; let groups = group_by_uid(parse(json)); let ics = to_ical("allday@google.com", &groups["allday@google.com"]).expect("convert"); assert!(ics.contains("DTSTART;VALUE=DATE:20240408"), "{ics}"); assert!(ics.contains("DTEND;VALUE=DATE:20240409"), "{ics}"); } const WITH_EXCEPTIONS: &str = r#"[ { "id": "m", "status": "confirmed", "summary": "Weekly", "start": {"dateTime": "2024-03-05T12:00:00+02:00", "timeZone": "Asia/Jerusalem"}, "end": {"dateTime": "2024-03-05T13:00:00+02:00", "timeZone": "Asia/Jerusalem"}, "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=TU"], "iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z" }, { "id": "m_20240319T100000Z", "status": "cancelled", "recurringEventId": "m", "originalStartTime": {"dateTime": "2024-03-19T12:00:00+02:00", "timeZone": "Asia/Jerusalem"}, "iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z" }, { "id": "m_20240326T100000Z", "status": "confirmed", "summary": "Weekly (moved)", "recurringEventId": "m", "originalStartTime": {"dateTime": "2024-03-26T12:00:00+02:00", "timeZone": "Asia/Jerusalem"}, "start": {"dateTime": "2024-03-26T15:00:00+02:00", "timeZone": "Asia/Jerusalem"}, "end": {"dateTime": "2024-03-26T16:00:00+02:00", "timeZone": "Asia/Jerusalem"}, "iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z" } ]"#; /// Exceptions share the master's iCalUID, so one file holds the series. #[test] fn exceptions_group_with_their_master() { let groups = group_by_uid(parse(WITH_EXCEPTIONS)); assert_eq!(groups.len(), 1); let group = &groups["m@google.com"]; assert!(group.master.is_some()); assert_eq!(group.overrides.len(), 2); } /// A deleted occurrence is an absence rather than an event. #[test] fn a_cancelled_occurrence_becomes_an_exdate_on_the_master() { let groups = group_by_uid(parse(WITH_EXCEPTIONS)); let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert"); assert!( ics.contains("EXDATE;TZID=Asia/Jerusalem:20240319T120000"), "{ics}" ); } #[test] fn a_moved_occurrence_becomes_a_recurrence_id_event() { let groups = group_by_uid(parse(WITH_EXCEPTIONS)); let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert"); assert!( ics.contains("RECURRENCE-ID;TZID=Asia/Jerusalem:20240326T120000"), "{ics}" ); assert!( ics.contains("DTSTART;TZID=Asia/Jerusalem:20240326T150000"), "{ics}" ); assert!(ics.contains("SUMMARY:Weekly (moved)")); } /// Whatever we render has to survive our own parser, or nothing downstream works. #[test] fn the_output_parses_as_icalendar() { let groups = group_by_uid(parse(WITH_EXCEPTIONS)); let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert"); let calendar = Calendar::parse(&ics).expect("output should be valid iCalendar"); assert_eq!(calendar.uid(), Some("m@google.com")); // Master plus the one moved occurrence; the cancelled one is an EXDATE. assert_eq!(calendar.properties("VEVENT", "UID").count(), 2); assert_eq!(calendar.properties("VEVENT", "RECURRENCE-ID").count(), 1); } /// Converts a captured API response and checks every group survives. /// /// Ignored by default: it needs a real payload, and calendar data is personal, /// so none is committed. Run against a capture with: /// CALCALIST_GOOGLE_FIXTURE=/path/to/events.json cargo test -- --ignored #[test] #[ignore = "requires a captured API response"] fn a_captured_response_converts_cleanly() { let Ok(path) = std::env::var("CALCALIST_GOOGLE_FIXTURE") else { panic!("set CALCALIST_GOOGLE_FIXTURE to a captured events response"); }; let body = std::fs::read_to_string(&path).expect("fixture should be readable"); let response: serde_json::Value = serde_json::from_str(&body).expect("fixture should be JSON"); let events: Vec = serde_json::from_value(response["items"].clone()).expect("items should deserialise"); let total = events.len(); let groups = group_by_uid(events); let mut converted = 0; for (uid, group) in &groups { if group.master.is_none() { // An exception whose master is outside this page; the caller // merges those into the stored file rather than rendering alone. continue; } let ics = to_ical(uid, group).unwrap_or_else(|error| panic!("{uid}: {error}")); Calendar::parse(&ics) .unwrap_or_else(|error| panic!("{uid} produced invalid iCal: {error}\n{ics}")); converted += 1; } println!( "{total} event(s) in {} group(s); {converted} rendered", groups.len() ); assert!(converted > 0, "nothing was converted"); } #[test] fn text_values_are_escaped() { let json = r#"[{ "id": "x", "status": "confirmed", "summary": "Lunch, then talk; bring notes", "start": {"date": "2026-02-02"}, "end": {"date": "2026-02-03"}, "iCalUID": "x@google.com", "updated": "2026-01-01T00:00:00.000Z" }]"#; let groups = group_by_uid(parse(json)); let ics = to_ical("x@google.com", &groups["x@google.com"]).expect("convert"); assert!( ics.contains(r"SUMMARY:Lunch\, then talk\; bring notes"), "{ics}" ); } }