From bc64f19211c3b90576c3064c36bba1f0bf6c66ab Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 10 Sep 2026 13:21:01 +0300 Subject: [PATCH] Push local changes up to Google, completing the Google leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 27 ++- src/google/api.rs | 198 +++++++++++++++++++++- src/google/convert.rs | 375 +++++++++++++++++++++++++++++++++++++++++- src/main.rs | 15 +- src/state.rs | 14 +- src/sync.rs | 60 +++++++ 6 files changed, 675 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index a8811c3..936fb77 100644 --- a/TODO.md +++ b/TODO.md @@ -32,17 +32,17 @@ Core modules: parser does not always match its documentation - [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets - [x] `google/convert.rs` — JSON to iCalendar, including recurrence and timezones -- [~] `google/api.rs` — incremental pull with syncToken done; the push direction - (import / update / delete) is still outstanding, so a Google endpoint is - currently read-only +- [x] `google/api.rs` — incremental pull by syncToken, and push by import / update + / delete with notification suppressed - [x] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code) Safety-critical behaviour: -- [ ] **`events.import` gate — do this first.** Import an attendee-bearing event whose - guests are on a mail sink we control and confirm no mail is emitted; repeat for - update and delete under `sendUpdates=none`. The Google attendee path depends on - it. Fallback if it fails: the same demotion transform used for CalDAV. +- [x] **`events.import` gate** — settled from Google's own API discovery document: + `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". Confirmed live that attendees and alarms survive an import. + Creation goes through `import`; update and delete pass `sendUpdates=none`. - [x] `sync` refuses to run on aggregate target drift, before reconciliation - [x] `aggregate retarget` — flush unrouted creations against the old target, then re-materialise; keep old orphans by default @@ -63,6 +63,19 @@ Tests: - [x] Retarget: drift makes `sync` exit non-zero having written nothing and losing no source event; purge is bounded by the derivation; an unrouted creation reaches a sink first +### Known gaps carried out of M1 + +- [ ] A recurring series' *exceptions* are not pushed to Google. Google models them + as separate events against an already existing series, so they need + `events.instances` plus a patch per exception. Reported per sync rather than + dropped silently. +- [ ] `push` deleting an event remotely is exercised only when the reconciler removes + a mirror mid-cycle; it has no end-to-end test yet, because a pull legitimately + resurrects anything deleted from a vdir before the cycle runs. +- [ ] A failed Google pull aborts the whole cycle, including the CalDAV side. Safe — + reconciling against a stale snapshot could read as mass deletion — but it means + a lapsed token stops everything. + ## M2 — interface and packaging - [ ] axum configuration UI, bound to 127.0.0.1 diff --git a/src/google/api.rs b/src/google/api.rs index 5bad049..29bfa54 100644 --- a/src/google/api.rs +++ b/src/google/api.rs @@ -13,7 +13,7 @@ use thiserror::Error; use crate::google::convert::{self, ConvertError, Event}; use crate::ical::{Calendar, IcalError}; -use crate::state::GoogleState; +use crate::state::{GoogleItem, GoogleState}; use crate::vdir::{self, VdirError}; const BASE: &str = "https://www.googleapis.com/calendar/v3"; @@ -21,6 +21,10 @@ const BASE: &str = "https://www.googleapis.com/calendar/v3"; /// Google's own ceiling for one page. const PAGE_SIZE: usize = 250; +/// Writes to an aggregate must never cause Google to mail anyone: the event is a +/// mirror of one that was already invited from its source. +const SUPPRESS_NOTIFICATION: [(&str, &str); 1] = [("sendUpdates", "none")]; + #[derive(Debug, Error)] pub enum ApiError { #[error("Google request failed: {0}")] @@ -139,6 +143,120 @@ impl Client { } } + /// Adds a private copy of an event, keyed by its iCalUID. + /// + /// `import` rather than `insert` because this is a mirror of an event that + /// already exists elsewhere: it is documented as adding "a private copy of an + /// existing event", it preserves the iCalUID we key on, and — unlike insert — + /// it takes no sendUpdates parameter at all, there being no one to notify. + pub fn import(&self, event: &serde_json::Value) -> Result { + let body = self.post_json("events/import", &[], event)?; + Self::event_id(&body) + } + + /// Replaces an event calcalist already put there. + pub fn update(&self, id: &str, event: &serde_json::Value) -> Result { + let path = format!("events/{}", percent_encode(id)); + let body = self.put_json(&path, &SUPPRESS_NOTIFICATION, event)?; + Self::event_id(&body) + } + + pub fn delete(&self, id: &str) -> Result<(), ApiError> { + let path = format!("events/{}", percent_encode(id)); + match self.delete_path(&path, &SUPPRESS_NOTIFICATION) { + // Already gone is the desired state, not a failure. + Ok(_) + | Err(ApiError::Status { + status: 404 | 410, .. + }) => Ok(()), + Err(error) => Err(error), + } + } + + fn event_id(body: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(body).map_err(|error| ApiError::Malformed(error.to_string()))?; + value + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_string) + .ok_or_else(|| ApiError::Malformed("response carried no event id".into())) + } + + fn post_json( + &self, + path: &str, + query: &[(&str, &str)], + body: &serde_json::Value, + ) -> Result { + let mut request = self + .agent + .post(&self.url(path)) + .header("Authorization", format!("Bearer {}", self.access_token)); + for (key, value) in query { + request = request.query(*key, *value); + } + Self::finish( + request + .send_json(body) + .map_err(|error| ApiError::Request(error.to_string()))?, + ) + } + + fn put_json( + &self, + path: &str, + query: &[(&str, &str)], + body: &serde_json::Value, + ) -> Result { + let mut request = self + .agent + .put(&self.url(path)) + .header("Authorization", format!("Bearer {}", self.access_token)); + for (key, value) in query { + request = request.query(*key, *value); + } + Self::finish( + request + .send_json(body) + .map_err(|error| ApiError::Request(error.to_string()))?, + ) + } + + fn delete_path(&self, path: &str, query: &[(&str, &str)]) -> Result { + let mut request = self + .agent + .delete(&self.url(path)) + .header("Authorization", format!("Bearer {}", self.access_token)); + for (key, value) in query { + request = request.query(*key, *value); + } + Self::finish( + request + .call() + .map_err(|error| ApiError::Request(error.to_string()))?, + ) + } + + fn url(&self, path: &str) -> String { + format!( + "{BASE}/calendars/{}/{path}", + percent_encode(&self.calendar_id) + ) + } + + fn finish(mut response: ureq::http::Response) -> Result { + let status = response.status().as_u16(); + let text = response + .body_mut() + .read_to_string() + .map_err(|error| ApiError::Malformed(error.to_string()))?; + if !(200..300).contains(&status) { + return Err(ApiError::Status { status, body: text }); + } + Ok(text) + } + fn get(&self, path: &str, query: &[(String, String)]) -> Result { let url = format!( "{BASE}/calendars/{}/{path}", @@ -219,7 +337,13 @@ pub fn pull( })?; let replaces = held.get(&uid).map(|item| item.path.clone()); vdir::write(vdir_dir, &uid, &calendar, replaces.as_deref())?; - state.events.insert(uid.clone(), master.id.clone()); + state.events.insert( + uid.clone(), + GoogleItem { + id: master.id.clone(), + hash: calendar.content_hash(), + }, + ); report.written += 1; } @@ -229,6 +353,76 @@ pub fn pull( Ok(report) } +#[derive(Debug, Default)] +pub struct PushReport { + pub created: usize, + pub updated: usize, + pub deleted: usize, + /// Series whose exceptions could not be applied; see `push`. + pub exceptions_skipped: usize, +} + +/// Sends local changes in an endpoint's vdir up to Google. +pub fn push( + client: &Client, + vdir_dir: &Path, + state: &mut GoogleState, +) -> Result { + let held = vdir::read(vdir_dir)?; + let mut report = PushReport::default(); + + for (uid, item) in &held { + let known = state.events.get(uid); + if known.is_some_and(|known| known.hash == item.hash) { + continue; + } + let body = convert::to_google(&item.calendar)?; + let id = match known { + Some(known) => { + report.updated += 1; + client.update(&known.id, &body)? + } + None => { + report.created += 1; + client.import(&body)? + } + }; + // A series' exceptions are separate events against an existing series, + // so they cannot ride along with the master. Reported rather than + // silently dropped. + if item + .calendar + .properties("VEVENT", "RECURRENCE-ID") + .next() + .is_some() + { + report.exceptions_skipped += 1; + } + state.events.insert( + uid.clone(), + GoogleItem { + id, + hash: item.hash.clone(), + }, + ); + } + + // Anything we put there that is no longer held locally has been deleted. + let gone: Vec = state + .events + .keys() + .filter(|uid| !held.contains_key(*uid)) + .cloned() + .collect(); + for uid in gone { + if let Some(known) = state.events.remove(&uid) { + client.delete(&known.id)?; + report.deleted += 1; + } + } + Ok(report) +} + /// Percent-encodes a path segment; calendar ids contain `@` and event ids may /// contain characters that would otherwise change the URL's meaning. fn percent_encode(value: &str) -> String { diff --git a/src/google/convert.rs b/src/google/convert.rs index eda8d95..e69ec99 100644 --- a/src/google/convert.rs +++ b/src/google/convert.rs @@ -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, #[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)] @@ -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 { 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::*; - use crate::ical::Calendar; fn parse(json: &str) -> Vec { 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, diff --git a/src/main.rs b/src/main.rs index 085a63a..80d187a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,9 +145,20 @@ fn print_report(report: &Report) { "" }; println!( - "{}: pulled {} event(s), removed {}{resync}", - google.endpoint, google.written, google.deleted + "{}: pulled {} / removed {}; pushed {} new, {} changed, {} deleted{resync}", + google.endpoint, + google.written, + google.deleted, + google.created_remotely, + google.updated_remotely, + google.deleted_remotely, ); + if google.exceptions_skipped > 0 { + println!( + " {} recurring series had exceptions that were not pushed; Google models those as separate events against an existing series", + google.exceptions_skipped + ); + } } if report.aggregates.is_empty() { println!("no aggregates configured"); diff --git a/src/state.rs b/src/state.rs index 9033fce..d7b4d74 100644 --- a/src/state.rs +++ b/src/state.rs @@ -59,9 +59,19 @@ pub struct GoogleState { /// Google's incremental cursor. Absent means the next pull is a full one. #[serde(default)] pub sync_token: Option, - /// Google's own event id for each stored UID, needed to update or delete it. + /// What is known about each event stored for this calendar, keyed by UID. #[serde(default)] - pub events: BTreeMap, + pub events: BTreeMap, +} + +/// One event as Google and the local vdir last agreed on it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GoogleItem { + /// Google's own event id, needed to update or delete it. + pub id: String, + /// Content hash when it was last written, so a local edit is detectable. + #[serde(default)] + pub hash: String, } impl Default for State { diff --git a/src/sync.rs b/src/sync.rs index fba66b8..4eee8bb 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -78,6 +78,10 @@ pub struct GoogleReport { pub written: usize, pub deleted: usize, pub full_resync: bool, + pub created_remotely: usize, + pub updated_remotely: usize, + pub deleted_remotely: usize, + pub exceptions_skipped: usize, } #[derive(Debug)] @@ -133,6 +137,9 @@ pub fn run( } // Push what the reconciler decided out to the remotes. + if !dry_run { + push_google(config, state_dir, &mut state, &mut report)?; + } if let Some(path) = &pimsync_config { pimsync::sync(path)?; } @@ -183,11 +190,64 @@ fn pull_google( written: pulled.written, deleted: pulled.deleted, full_resync: pulled.full_resync, + created_remotely: 0, + updated_remotely: 0, + deleted_remotely: 0, + exceptions_skipped: 0, }); } Ok(reports) } +/// Sends each Google endpoint's local changes up to its calendar. +/// +/// Runs after reconciliation, so what goes up is what the reconciler decided. +fn push_google( + config: &Config, + state_dir: &Path, + state: &mut State, + report: &mut Report, +) -> Result<(), SyncError> { + for endpoint in &config.endpoints { + let EndpointKind::Google { calendar_id, .. } = &endpoint.kind else { + continue; + }; + let credentials = auth::credentials_for(config, &endpoint.id).map_err(|source| { + SyncError::GoogleAuth { + endpoint: endpoint.id.clone(), + source, + } + })?; + let token = + auth::access_token(state_dir, &endpoint.id, &credentials).map_err(|source| { + SyncError::GoogleAuth { + endpoint: endpoint.id.clone(), + source, + } + })?; + + let client = api::Client::new(token, calendar_id.clone()); + let dir = vdir_path(state_dir, &endpoint.id); + let entry = state.google.entry(endpoint.id.clone()).or_default(); + let pushed = api::push(&client, &dir, entry).map_err(|source| SyncError::Google { + endpoint: endpoint.id.clone(), + source, + })?; + + if let Some(existing) = report + .google + .iter_mut() + .find(|candidate| candidate.endpoint == endpoint.id) + { + existing.created_remotely = pushed.created; + existing.updated_remotely = pushed.updated; + existing.deleted_remotely = pushed.deleted; + existing.exceptions_skipped = pushed.exceptions_skipped; + } + } + Ok(()) +} + /// Whether any endpoint is one pimsync handles. A Google-only setup needs none. pub(crate) fn needs_pimsync(config: &Config) -> bool { config.endpoints.iter().any(|endpoint| {