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| {