//! 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::ical::Calendar; 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, #[serde(default)] pub reminders: Option, } /// Google models alarms as minutes before the start, rather than as components. /// /// `useDefault` is deliberately not read: it names the calendar's own default /// reminders, which are a property of that calendar rather than of the event, so /// there is nothing to carry. An empty `overrides` already yields no alarm. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Reminders { #[serde(default)] pub overrides: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct Reminder { pub method: String, pub minutes: i64, } #[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")); } // Alarms are always carried across. `useDefault` is a property of the // calendar rather than the event, so it has no VALARM to become — the // aggregate's own defaults apply instead. for reminder in event .reminders .iter() .flat_map(|reminders| reminders.overrides.iter()) { let action = if reminder.method == "email" { "EMAIL" } else { "DISPLAY" }; out.push_str(&format!( "BEGIN:VALARM\r\nACTION:{action}\r\nTRIGGER:-PT{}M\r\nDESCRIPTION:Reminder\r\nEND:VALARM\r\n", reminder.minutes )); } 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()) } /// Renders a stored calendar as the JSON body Google expects. /// /// Only the master is described. A series' exceptions cannot be expressed in the /// same request — Google models them as separate events against an already /// existing series — so the caller applies those afterwards. pub fn to_google(calendar: &Calendar) -> Result { let uid = calendar.uid().ok_or_else(|| ConvertError::NoUid { id: "".to_string(), })?; let mut event = serde_json::Map::new(); event.insert("iCalUID".into(), uid.into()); event.insert("status".into(), status_of(calendar).into()); for (property, field) in [ ("SUMMARY", "summary"), ("DESCRIPTION", "description"), ("LOCATION", "location"), ] { if let Some(value) = master_property(calendar, property) { event.insert(field.into(), unescape_text(&value).into()); } } event.insert("start".into(), google_time(calendar, "DTSTART")?); if let Some(end) = calendar .properties("VEVENT", "DTEND") .next() .map(|_| google_time(calendar, "DTEND")) .transpose()? { event.insert("end".into(), end); } // RRULE, EXDATE and RDATE are iCalendar lines on both sides, so they travel // verbatim. Their unfolded form is what Google expects. let recurrence: Vec = ["RRULE", "EXDATE", "RDATE"] .iter() .flat_map(|name| calendar.property_lines("VEVENT", name)) .map(|line| serde_json::Value::from(line.to_string())) .collect(); if !recurrence.is_empty() { event.insert("recurrence".into(), recurrence.into()); } if let Some(transp) = master_property(calendar, "TRANSP") { let value = if transp.eq_ignore_ascii_case("TRANSPARENT") { "transparent" } else { "opaque" }; event.insert("transparency".into(), value.into()); } let attendees: Vec = calendar .properties("VEVENT", "ATTENDEE") .map(|attendee| { let mut person = serde_json::Map::new(); person.insert("email".into(), strip_scheme(attendee.value).into()); if let Some(name) = attendee.param("CN") { person.insert("displayName".into(), name.into()); } if let Some(status) = attendee.param("PARTSTAT") { person.insert("responseStatus".into(), response_status(status).into()); } serde_json::Value::Object(person) }) .collect(); if !attendees.is_empty() { event.insert("attendees".into(), attendees.into()); } // Without this Google silently substitutes the calendar's default reminders, // which loses the alarms the event actually carried. let overrides: Vec = calendar .properties("VALARM", "TRIGGER") .filter_map(|trigger| minutes_before(trigger.value)) .zip(alarm_methods(calendar)) .map(|(minutes, method)| serde_json::json!({ "method": method, "minutes": minutes })) .collect(); if !overrides.is_empty() { event.insert( "reminders".into(), serde_json::json!({ "useDefault": false, "overrides": overrides }), ); } Ok(serde_json::Value::Object(event)) } /// The Google reminder method for each VALARM, in the order they appear. fn alarm_methods(calendar: &Calendar) -> Vec<&'static str> { calendar .properties("VALARM", "ACTION") .map(|action| { if action.value.eq_ignore_ascii_case("EMAIL") { "email" } else { "popup" } }) .collect() } /// Converts a relative TRIGGER into whole minutes before the start. /// /// Google expresses alarms only that way, so an absolute trigger, or one set /// *after* the start, has no representation and is dropped rather than guessed at. fn minutes_before(trigger: &str) -> Option { let rest = trigger.strip_prefix('-')?.strip_prefix('P')?; let (days, rest) = split_unit(rest, 'D'); let time = rest.strip_prefix('T').unwrap_or(rest); let (hours, time) = split_unit(time, 'H'); let (minutes, time) = split_unit(time, 'M'); let (seconds, _) = split_unit(time, 'S'); let total = days * 24 * 60 + hours * 60 + minutes + seconds / 60; (total > 0).then_some(total) } /// Splits a leading `` off a duration, if the next unit matches. fn split_unit(text: &str, unit: char) -> (i64, &str) { let Some(index) = text.find(unit) else { return (0, text); }; match text[..index].parse::() { Ok(value) => (value, &text[index + 1..]), Err(_) => (0, text), } } /// The value of a property on the master event — the one without a RECURRENCE-ID. fn master_property(calendar: &Calendar, name: &str) -> Option { calendar .properties("VEVENT", name) .next() .map(|property| property.value.to_string()) } fn status_of(calendar: &Calendar) -> &'static str { match master_property(calendar, "STATUS").as_deref() { Some(value) if value.eq_ignore_ascii_case("TENTATIVE") => "tentative", Some(value) if value.eq_ignore_ascii_case("CANCELLED") => "cancelled", _ => "confirmed", } } fn response_status(partstat: &str) -> &'static str { match partstat.to_ascii_uppercase().as_str() { "ACCEPTED" => "accepted", "DECLINED" => "declined", "TENTATIVE" => "tentative", _ => "needsAction", } } fn strip_scheme(value: &str) -> &str { value.split_once(':').map_or(value, |(_, rest)| rest) } /// Converts a stored DTSTART or DTEND back into Google's representation. fn google_time(calendar: &Calendar, name: &str) -> Result { let property = calendar .properties("VEVENT", name) .next() .ok_or_else(|| ConvertError::NoStart { id: "".to_string(), })?; let value = property.value; let mut time = serde_json::Map::new(); if property.param("VALUE") == Some("DATE") || value.len() == 8 { time.insert("date".into(), hyphenate(value).into()); return Ok(serde_json::Value::Object(time)); } // A zoned local time has to become an absolute instant, which needs the zone // to resolve the offset for that date — the same conversion as the inbound // direction, in reverse. let zone_name = property.param("TZID"); let zone = zone_name .and_then(|name| TimeZone::get(name).ok()) .unwrap_or(TimeZone::UTC); let civil = parse_basic(value, "DTSTART")?; let zoned = civil .to_zoned(zone) .map_err(|source| ConvertError::BadTime { id: "".to_string(), field: "DTSTART", value: value.to_string(), source, })?; time.insert("dateTime".into(), zoned.timestamp().to_string().into()); if let Some(zone_name) = zone_name { time.insert("timeZone".into(), zone_name.into()); } Ok(serde_json::Value::Object(time)) } /// Parses `YYYYMMDDTHHMMSS`, with or without a trailing Z. fn parse_basic(value: &str, field: &'static str) -> Result { let trimmed = value.trim_end_matches('Z'); jiff::civil::DateTime::strptime("%Y%m%dT%H%M%S", trimmed).map_err(|source| { ConvertError::BadTime { id: "".to_string(), field, value: value.to_string(), source, } }) } fn hyphenate(basic: &str) -> String { if basic.len() != 8 { return basic.to_string(); } format!("{}-{}-{}", &basic[..4], &basic[4..6], &basic[6..]) } /// Reverses the escaping RFC 5545 requires of TEXT values. fn unescape_text(value: &str) -> String { let mut out = String::with_capacity(value.len()); let mut chars = value.chars(); while let Some(ch) = chars.next() { if ch != '\\' { out.push(ch); continue; } match chars.next() { Some('n' | 'N') => out.push('\n'), Some(escaped) => out.push(escaped), None => out.push('\\'), } } out } #[cfg(test)] mod tests { use super::*; 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); } #[test] fn trigger_durations_become_minutes() { assert_eq!(minutes_before("-PT15M"), Some(15)); assert_eq!(minutes_before("-PT1H"), Some(60)); assert_eq!(minutes_before("-P1D"), Some(1440)); assert_eq!(minutes_before("-P1DT2H30M"), Some(1590)); // Google has no way to express these, so they are dropped rather than guessed. assert_eq!(minutes_before("PT15M"), None, "after the start"); assert_eq!(minutes_before("-PT0S"), None, "at the start"); assert_eq!(minutes_before("20260101T090000Z"), None, "absolute"); } /// An alarm must survive a round trip, since alarms are never stripped. #[test] fn alarms_survive_conversion_to_google() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:a@calcalist\r\nDTSTART:20260915T090000Z\r\nDTEND:20260915T100000Z\r\nSUMMARY:With alarm\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let calendar = Calendar::parse(ics).expect("parse"); let json = to_google(&calendar).expect("convert"); let reminders = &json["reminders"]; assert_eq!(reminders["useDefault"], serde_json::json!(false)); assert_eq!(reminders["overrides"][0]["minutes"], serde_json::json!(15)); assert_eq!( reminders["overrides"][0]["method"], serde_json::json!("popup") ); } #[test] fn google_reminders_become_alarms() { let json = r#"[{ "id": "r", "status": "confirmed", "summary": "Reminded", "start": {"dateTime": "2026-09-15T09:00:00Z"}, "end": {"dateTime": "2026-09-15T10:00:00Z"}, "iCalUID": "r@google.com", "updated": "2026-01-01T00:00:00.000Z", "reminders": {"useDefault": false, "overrides": [{"method": "popup", "minutes": 30}]} }]"#; let groups = group_by_uid(parse(json)); let ics = to_ical("r@google.com", &groups["r@google.com"]).expect("convert"); assert!(ics.contains("BEGIN:VALARM"), "{ics}"); assert!(ics.contains("TRIGGER:-PT30M"), "{ics}"); } /// useDefault is a property of the calendar, not the event, so there is no /// alarm to carry. #[test] fn default_reminders_produce_no_alarm() { let json = r#"[{ "id": "d", "status": "confirmed", "summary": "Default", "start": {"dateTime": "2026-09-15T09:00:00Z"}, "end": {"dateTime": "2026-09-15T10:00:00Z"}, "iCalUID": "d@google.com", "updated": "2026-01-01T00:00:00.000Z", "reminders": {"useDefault": true} }]"#; let groups = group_by_uid(parse(json)); let ics = to_ical("d@google.com", &groups["d@google.com"]).expect("convert"); assert!(!ics.contains("VALARM"), "{ics}"); } #[test] fn attendees_and_recurrence_reach_google() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:b@calcalist\r\nDTSTART;TZID=Asia/Jerusalem:20260312T161500\r\nRRULE:FREQ=WEEKLY;BYDAY=TH\r\nSUMMARY:Series\r\nATTENDEE;CN=Guest;PARTSTAT=ACCEPTED:mailto:guest@example.com\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let calendar = Calendar::parse(ics).expect("parse"); let json = to_google(&calendar).expect("convert"); assert_eq!( json["recurrence"][0], serde_json::json!("RRULE:FREQ=WEEKLY;BYDAY=TH") ); assert_eq!( json["attendees"][0]["email"], serde_json::json!("guest@example.com") ); assert_eq!( json["attendees"][0]["responseStatus"], serde_json::json!("accepted") ); assert_eq!( json["start"]["timeZone"], serde_json::json!("Asia/Jerusalem") ); // 16:15 Jerusalem in March is +02:00, so 14:15Z. assert!( json["start"]["dateTime"] .as_str() .expect("dateTime") .starts_with("2026-03-12T14:15:00"), "{}", json["start"]["dateTime"] ); } #[test] fn an_all_day_event_returns_as_a_date() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:c@calcalist\r\nDTSTART;VALUE=DATE:20261225\r\nDTEND;VALUE=DATE:20261226\r\nSUMMARY:Christmas\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let calendar = Calendar::parse(ics).expect("parse"); let json = to_google(&calendar).expect("convert"); assert_eq!(json["start"]["date"], serde_json::json!("2026-12-25")); assert_eq!(json["end"]["date"], serde_json::json!("2026-12-26")); } /// 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}" ); } }