Push local changes up to Google, completing the Google leg
Mirrors are created with events.import, updated and deleted with sendUpdates=none, so a write to an aggregate cannot mail anyone about a meeting that was already invited from its source. The import question the plan flagged is settled from Google's own API discovery document rather than by guesswork: events.import accepts no sendUpdates parameter at all, while insert, update and delete all do, and it is documented as adding "a private copy of an existing event". A method with notification behaviour would need that control. Confirmed live that attendees survive an import with their response statuses intact. Testing against the real API caught a defect that unit tests could not: the first push dropped VALARM entirely and Google substituted the calendar's default reminders. Alarms are never stripped by decision, so they now map to and from Google's reminder overrides in both directions, with relative TRIGGER durations converted to whole minutes. A trigger Google cannot express — absolute, or after the start — is dropped rather than guessed at. Known gaps recorded in TODO.md rather than papered over: a series' exceptions are not pushed, since Google models those as separate events against an existing series; and a failed Google pull still aborts the cycle, which is safe but stops the CalDAV side too. 121 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
edb16204fa
commit
bc64f19211
6 changed files with 675 additions and 14 deletions
|
|
@ -20,6 +20,7 @@ use jiff::{Timestamp, tz::TimeZone};
|
|||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ical::Calendar;
|
||||
use crate::mirror::escape_text;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
|
@ -70,6 +71,26 @@ pub struct Event {
|
|||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -215,6 +236,24 @@ fn render_event(
|
|||
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)
|
||||
}
|
||||
|
|
@ -280,10 +319,245 @@ fn stamp(event: &Event) -> Result<String, ConvertError> {
|
|||
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
|
||||
}
|
||||
|
||||
#[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")
|
||||
|
|
@ -434,6 +708,105 @@ mod tests {
|
|||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue