CalCalist/src/google/convert.rs

864 lines
32 KiB
Rust
Raw Normal View History

Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
//! 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;
Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
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>,
#[serde(default)]
pub reminders: Option<Reminders>,
}
/// 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<Reminder>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Reminder {
pub method: String,
pub minutes: i64,
Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
}
#[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"));
}
// 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
));
}
Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
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())
}
/// 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<serde_json::Value, ConvertError> {
let uid = calendar.uid().ok_or_else(|| ConvertError::NoUid {
id: "<local>".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<serde_json::Value> = ["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<serde_json::Value> = 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<serde_json::Value> = 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<i64> {
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 `<number><unit>` 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::<i64>() {
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<String> {
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<serde_json::Value, ConvertError> {
let property =
calendar
.properties("VEVENT", name)
.next()
.ok_or_else(|| ConvertError::NoStart {
id: "<local>".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: "<local>".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<jiff::civil::DateTime, ConvertError> {
let trimmed = value.trim_end_matches('Z');
jiff::civil::DateTime::strptime("%Y%m%dT%H%M%S", trimmed).map_err(|source| {
ConvertError::BadTime {
id: "<local>".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
}
Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
#[cfg(test)]
mod tests {
use super::*;
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);
}
#[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"));
}
Pull Google calendars into their local vdirs The Google equivalent of the pimsync pull, which pimsync cannot do: incremental fetch by syncToken, converted to iCalendar and written into the endpoint's vdir. A cursor Google no longer accepts comes back as 410, which means start again rather than something broke, so that case refetches instead of failing. The conversion was written against what the API actually returns, having probed a real calendar first. Three findings shaped it: - A recurring event's exceptions carry the same iCalUID as their master, so a series belongs in one file, which is exactly the vdir convention. - 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 reads 2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00. Emitting the instant under that TZID unconverted would move it three hours, so the instant is converted into its zone. That needs a timezone database, hence jiff. - A deleted occurrence arrives as an override with status cancelled. That is an absence rather than an event, so it becomes an EXDATE on the master; moved occurrences become RECURRENCE-ID events. Timed values are written in UTC unless the event recurs. Only a recurrence needs a zone to expand in, and confining TZID to those events limits how far we depend on clients tolerating a TZID with no VTIMEZONE alongside it. Verified against a live calendar: 30 real events, including a weekly series with both a cancelled and several moved occurrences, converted and re-parsed intact. The retarget tests no longer run a full cycle. They had used Google endpoints as inert local stand-ins, which stopped being true the moment sync learned to contact Google; they now reconcile directly. 115 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:13:26 +03:00
/// 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}"
);
}
}