491 lines
18 KiB
Rust
491 lines
18 KiB
Rust
|
|
//! 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<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub status: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub summary: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub description: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub location: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub start: Option<EventTime>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub end: Option<EventTime>,
|
||
|
|
/// Raw iCalendar recurrence lines — RRULE, EXDATE, RDATE — passed through.
|
||
|
|
#[serde(default)]
|
||
|
|
pub recurrence: Option<Vec<String>>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub recurring_event_id: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub original_start_time: Option<EventTime>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub transparency: Option<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub sequence: Option<i64>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub updated: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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<String>,
|
||
|
|
/// An absolute instant, RFC 3339.
|
||
|
|
#[serde(default)]
|
||
|
|
pub date_time: Option<String>,
|
||
|
|
/// The zone the recurrence expands in — not necessarily the offset above.
|
||
|
|
#[serde(default)]
|
||
|
|
pub time_zone: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<Event>,
|
||
|
|
pub overrides: Vec<Event>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Groups a page of events by the UID they will be stored under.
|
||
|
|
pub fn group_by_uid(events: Vec<Event>) -> BTreeMap<String, Group> {
|
||
|
|
let mut groups: BTreeMap<String, Group> = 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<String, ConvertError> {
|
||
|
|
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<String, ConvertError> {
|
||
|
|
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<String, ConvertError> {
|
||
|
|
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<String, ConvertError> {
|
||
|
|
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<Event> {
|
||
|
|
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<Event> =
|
||
|
|
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}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|