diff --git a/Cargo.lock b/Cargo.lock index c0f1e1f..1299f71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -110,6 +116,7 @@ version = "0.1.0" dependencies = [ "blake3", "clap", + "jiff", "serde", "serde_json", "sha2", @@ -249,6 +256,37 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "deranged" version = "0.5.8" @@ -519,6 +557,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "libc" version = "0.2.189" @@ -589,6 +680,21 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -648,7 +754,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", diff --git a/Cargo.toml b/Cargo.toml index ce6877c..c8cac75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] blake3 = "1.8" clap = { version = "4.6", features = ["derive"] } +jiff = "0.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.11" diff --git a/TODO.md b/TODO.md index 65f152e..a8811c3 100644 --- a/TODO.md +++ b/TODO.md @@ -31,7 +31,10 @@ Core modules: - [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's parser does not always match its documentation - [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets -- [ ] `google/api.rs`, `google/convert.rs` +- [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] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code) Safety-critical behaviour: diff --git a/src/google/api.rs b/src/google/api.rs new file mode 100644 index 0000000..5bad049 --- /dev/null +++ b/src/google/api.rs @@ -0,0 +1,245 @@ +//! The Google Calendar REST client. +//! +//! This does for Google what pimsync does for CalDAV: keeps a local vdir in step +//! with the remote calendar. Only the pull direction lives here so far. +//! +//! Incremental sync uses Google's `syncToken`. A token Google no longer accepts +//! comes back as 410, which means "start again" rather than "something broke" — +//! it happens routinely after a long enough gap. + +use std::path::Path; + +use thiserror::Error; + +use crate::google::convert::{self, ConvertError, Event}; +use crate::ical::{Calendar, IcalError}; +use crate::state::GoogleState; +use crate::vdir::{self, VdirError}; + +const BASE: &str = "https://www.googleapis.com/calendar/v3"; + +/// Google's own ceiling for one page. +const PAGE_SIZE: usize = 250; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("Google request failed: {0}")] + Request(String), + #[error("Google returned {status}: {body}")] + Status { status: u16, body: String }, + #[error("could not read Google's response: {0}")] + Malformed(String), + #[error(transparent)] + Convert(#[from] ConvertError), + #[error(transparent)] + Vdir(#[from] VdirError), + #[error("calcalist produced iCalendar Google's event {id} could not be stored as: {source}")] + Rendered { + id: String, + #[source] + source: IcalError, + }, +} + +pub struct Client { + agent: ureq::Agent, + access_token: String, + calendar_id: String, +} + +/// A page of changes, and the cursor to resume from. +#[derive(Debug, Default)] +pub struct Changes { + pub events: Vec, + pub next_sync_token: Option, + /// Set when Google rejected the cursor and everything was refetched. + pub was_full_resync: bool, +} + +impl Client { + pub fn new(access_token: String, calendar_id: String) -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .build() + .into(); + Client { + agent, + access_token, + calendar_id, + } + } + + /// Every change since `sync_token`, following pagination to the end. + pub fn changes(&self, sync_token: Option<&str>) -> Result { + match self.collect(sync_token) { + Err(ApiError::Status { status: 410, .. }) if sync_token.is_some() => { + // The cursor has aged out. Refetching everything is the documented + // recovery, not an error to report. + let mut full = self.collect(None)?; + full.was_full_resync = true; + Ok(full) + } + other => other, + } + } + + fn collect(&self, sync_token: Option<&str>) -> Result { + let mut changes = Changes::default(); + let mut page_token: Option = None; + loop { + let mut query = vec![ + ("maxResults".to_string(), PAGE_SIZE.to_string()), + ("showDeleted".to_string(), "true".to_string()), + // Masters and their exceptions, not expanded occurrences: an + // expansion would lose the recurrence rule entirely. + ("singleEvents".to_string(), "false".to_string()), + ]; + if let Some(token) = sync_token { + query.push(("syncToken".to_string(), token.to_string())); + } + if let Some(token) = &page_token { + query.push(("pageToken".to_string(), token.clone())); + } + + let body = self.get("events", &query)?; + let page: serde_json::Value = serde_json::from_str(&body) + .map_err(|error| ApiError::Malformed(error.to_string()))?; + + let items = page + .get("items") + .cloned() + .unwrap_or(serde_json::Value::Null); + if !items.is_null() { + let parsed: Vec = serde_json::from_value(items) + .map_err(|error| ApiError::Malformed(error.to_string()))?; + changes.events.extend(parsed); + } + match page.get("nextPageToken").and_then(|value| value.as_str()) { + Some(next) => page_token = Some(next.to_string()), + None => { + changes.next_sync_token = page + .get("nextSyncToken") + .and_then(|value| value.as_str()) + .map(str::to_string); + return Ok(changes); + } + } + } + } + + /// Fetches one event, used when an exception arrives without its master. + fn event(&self, id: &str) -> Result, ApiError> { + let path = format!("events/{}", percent_encode(id)); + match self.get(&path, &[]) { + Ok(body) => serde_json::from_str(&body) + .map(Some) + .map_err(|error| ApiError::Malformed(error.to_string())), + Err(ApiError::Status { status: 404, .. }) => Ok(None), + Err(error) => Err(error), + } + } + + fn get(&self, path: &str, query: &[(String, String)]) -> Result { + let url = format!( + "{BASE}/calendars/{}/{path}", + percent_encode(&self.calendar_id) + ); + let mut request = self + .agent + .get(&url) + .header("Authorization", format!("Bearer {}", self.access_token)); + for (key, value) in query { + request = request.query(key, value); + } + let mut response = request + .call() + .map_err(|error| ApiError::Request(error.to_string()))?; + let status = response.status().as_u16(); + let body = 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 }); + } + Ok(body) + } +} + +#[derive(Debug, Default)] +pub struct PullReport { + pub written: usize, + pub deleted: usize, + pub full_resync: bool, +} + +/// Brings an endpoint's local vdir into step with its Google calendar. +pub fn pull( + client: &Client, + vdir_dir: &Path, + state: &mut GoogleState, +) -> Result { + let changes = client.changes(state.sync_token.as_deref())?; + let mut report = PullReport { + full_resync: changes.was_full_resync, + ..PullReport::default() + }; + // A refetch describes the calendar in full, so anything it does not mention + // is gone; without that, stale files would linger forever. + let held = vdir::read(vdir_dir)?; + let groups = convert::group_by_uid(changes.events); + + for (uid, mut group) in groups { + // An exception can arrive without its master on an incremental pull; + // the series cannot be rendered from the exception alone. + if group.master.is_none() + && let Some(parent) = group + .overrides + .first() + .and_then(|event| event.recurring_event_id.clone()) + { + group.master = client.event(&parent)?; + } + let Some(master) = &group.master else { + continue; + }; + if master.is_cancelled() { + if let Some(item) = held.get(&uid) { + vdir::remove(&item.path)?; + report.deleted += 1; + } + state.events.remove(&uid); + continue; + } + + let ics = convert::to_ical(&uid, &group)?; + let calendar = Calendar::parse(&ics).map_err(|source| ApiError::Rendered { + id: master.id.clone(), + source, + })?; + 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()); + report.written += 1; + } + + if changes.next_sync_token.is_some() { + state.sync_token = changes.next_sync_token; + } + 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 { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/src/google/convert.rs b/src/google/convert.rs new file mode 100644 index 0000000..eda8d95 --- /dev/null +++ b/src/google/convert.rs @@ -0,0 +1,490 @@ +//! Turning Google's JSON events into iCalendar, and back. +//! +//! The shapes here were established by reading what the API actually returns, +//! not from the reference alone. Three things drive the design: +//! +//! * A recurring event's exceptions carry the **same `iCalUID`** as their master, +//! so a master and all its exceptions belong in one file — which is exactly the +//! vdir convention of one file per UID. +//! * `start.dateTime` is an absolute instant while `start.timeZone` names the zone +//! the recurrence expands in, and **the two need not agree**: a real event here +//! reads `2023-10-31T13:00:00+02:00` with `timeZone: Asia/Karachi`, which is +//! +05:00. Emitting the instant under that TZID without converting it would move +//! the event by three hours. +//! * An exception that was deleted comes back as an override with +//! `status: cancelled`, which is an `EXDATE` on the master rather than an event. + +use std::collections::BTreeMap; + +use jiff::{Timestamp, tz::TimeZone}; +use serde::Deserialize; +use thiserror::Error; + +use crate::mirror::escape_text; + +#[derive(Debug, Error)] +pub enum ConvertError { + #[error("event {id} has no iCalUID, so it cannot be stored")] + NoUid { id: String }, + #[error("event {id} has neither a start date nor a start time")] + NoStart { id: String }, + #[error("event {id} has an unparseable {field} `{value}`: {source}")] + BadTime { + id: String, + field: &'static str, + value: String, + #[source] + source: jiff::Error, + }, +} + +/// A Google calendar event, limited to the fields calcalist maps. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Event { + pub id: String, + #[serde(rename = "iCalUID")] + pub ical_uid: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub start: Option, + #[serde(default)] + pub end: Option, + /// Raw iCalendar recurrence lines — RRULE, EXDATE, RDATE — passed through. + #[serde(default)] + pub recurrence: Option>, + #[serde(default)] + pub recurring_event_id: Option, + #[serde(default)] + pub original_start_time: Option, + #[serde(default)] + pub transparency: Option, + #[serde(default)] + pub sequence: Option, + #[serde(default)] + pub updated: Option, +} + +#[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, + /// An absolute instant, RFC 3339. + #[serde(default)] + pub date_time: Option, + /// The zone the recurrence expands in — not necessarily the offset above. + #[serde(default)] + pub time_zone: Option, +} + +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, + pub overrides: Vec, +} + +/// Groups a page of events by the UID they will be stored under. +pub fn group_by_uid(events: Vec) -> BTreeMap { + let mut groups: BTreeMap = 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 { + 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 { + let recurring = event.is_recurring() || recurrence_id.is_some(); + let mut out = String::from("BEGIN:VEVENT\r\n"); + out.push_str(&format!("UID:{uid}\r\n")); + out.push_str(&format!("DTSTAMP:{}\r\n", stamp(event)?)); + + if let Some(original) = recurrence_id { + out.push_str(&time_property( + "RECURRENCE-ID", + original, + &event.id, + recurring, + )?); + out.push_str("\r\n"); + } + let start = event.start.as_ref().ok_or_else(|| ConvertError::NoStart { + id: event.id.clone(), + })?; + out.push_str(&time_property("DTSTART", start, &event.id, recurring)?); + out.push_str("\r\n"); + if let Some(end) = &event.end { + out.push_str(&time_property("DTEND", end, &event.id, recurring)?); + out.push_str("\r\n"); + } + for line in event.recurrence.iter().flatten() { + out.push_str(line); + out.push_str("\r\n"); + } + for (name, value) in [ + ("SUMMARY", event.summary.as_deref()), + ("DESCRIPTION", event.description.as_deref()), + ("LOCATION", event.location.as_deref()), + ] { + if let Some(value) = value { + out.push_str(&format!("{name}:{}\r\n", escape_text(value))); + } + } + if let Some(transparency) = &event.transparency { + let value = if transparency == "transparent" { + "TRANSPARENT" + } else { + "OPAQUE" + }; + out.push_str(&format!("TRANSP:{value}\r\n")); + } + if event.status.as_deref() == Some("tentative") { + out.push_str("STATUS:TENTATIVE\r\n"); + } + if let Some(sequence) = event.sequence { + out.push_str(&format!("SEQUENCE:{sequence}\r\n")); + } + out.push_str("END:VEVENT\r\n"); + Ok(out) +} + +/// Renders one date-or-time property. +/// +/// Timed values are written in UTC unless the event recurs. A recurrence has to +/// expand in its own zone or it drifts by an hour across a daylight-saving +/// change, so those carry a TZID — and the instant is converted into that zone +/// first, since Google's offset and its `timeZone` may disagree. +fn time_property( + name: &str, + time: &EventTime, + id: &str, + recurring: bool, +) -> Result { + 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 { + let Some(updated) = &event.updated else { + return Ok(Timestamp::now().strftime("%Y%m%dT%H%M%SZ").to_string()); + }; + let instant: Timestamp = updated.parse().map_err(|source| ConvertError::BadTime { + id: event.id.clone(), + field: "updated", + value: updated.clone(), + source, + })?; + Ok(instant.strftime("%Y%m%dT%H%M%SZ").to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ical::Calendar; + + fn parse(json: &str) -> Vec { + serde_json::from_str(json).expect("fixture should deserialise") + } + + /// Taken verbatim from a real calendar: the offset is +02:00 while the zone + /// is Asia/Karachi, which is +05:00. + const KARACHI_MASTER: &str = r#"[{ + "id": "master1", + "status": "confirmed", + "summary": "Flux Dev Meeting", + "start": {"dateTime": "2023-10-31T13:00:00+02:00", "timeZone": "Asia/Karachi"}, + "end": {"dateTime": "2023-10-31T14:00:00+02:00", "timeZone": "Asia/Karachi"}, + "recurrence": ["RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=1;BYDAY=TU"], + "iCalUID": "master1@google.com", + "sequence": 1, + "updated": "2024-06-18T10:04:30.544Z" + }]"#; + + /// The instant is 11:00Z; under Asia/Karachi that is 16:00, not the 13:00 + /// the offset in the payload suggests. + #[test] + fn a_recurring_start_is_converted_into_its_own_zone() { + let groups = group_by_uid(parse(KARACHI_MASTER)); + let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert"); + assert!( + ics.contains("DTSTART;TZID=Asia/Karachi:20231031T160000"), + "{ics}" + ); + assert!( + ics.contains("DTEND;TZID=Asia/Karachi:20231031T170000"), + "{ics}" + ); + } + + #[test] + fn recurrence_lines_pass_straight_through() { + let groups = group_by_uid(parse(KARACHI_MASTER)); + let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert"); + assert!(ics.contains("RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=1;BYDAY=TU")); + } + + /// A one-off needs no zone to expand in, so UTC keeps it unambiguous and + /// avoids referencing a VTIMEZONE that is not there. + #[test] + fn a_single_event_is_written_in_utc() { + let json = r#"[{ + "id": "one", + "status": "confirmed", + "summary": "Dentist", + "start": {"dateTime": "2026-01-08T14:30:00+02:00", "timeZone": "Asia/Jerusalem"}, + "end": {"dateTime": "2026-01-08T15:30:00+02:00", "timeZone": "Asia/Jerusalem"}, + "iCalUID": "one@google.com", + "updated": "2026-01-01T00:00:00.000Z" + }]"#; + let groups = group_by_uid(parse(json)); + let ics = to_ical("one@google.com", &groups["one@google.com"]).expect("convert"); + assert!(ics.contains("DTSTART:20260108T123000Z"), "{ics}"); + assert!(!ics.contains("TZID"), "{ics}"); + } + + #[test] + fn an_all_day_event_keeps_its_date_form() { + let json = r#"[{ + "id": "allday", + "status": "confirmed", + "summary": "Holiday", + "start": {"date": "2024-04-08"}, + "end": {"date": "2024-04-09"}, + "iCalUID": "allday@google.com", + "updated": "2024-01-01T00:00:00.000Z" + }]"#; + let groups = group_by_uid(parse(json)); + let ics = to_ical("allday@google.com", &groups["allday@google.com"]).expect("convert"); + assert!(ics.contains("DTSTART;VALUE=DATE:20240408"), "{ics}"); + assert!(ics.contains("DTEND;VALUE=DATE:20240409"), "{ics}"); + } + + const WITH_EXCEPTIONS: &str = r#"[ + { + "id": "m", "status": "confirmed", "summary": "Weekly", + "start": {"dateTime": "2024-03-05T12:00:00+02:00", "timeZone": "Asia/Jerusalem"}, + "end": {"dateTime": "2024-03-05T13:00:00+02:00", "timeZone": "Asia/Jerusalem"}, + "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=TU"], + "iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z" + }, + { + "id": "m_20240319T100000Z", "status": "cancelled", + "recurringEventId": "m", + "originalStartTime": {"dateTime": "2024-03-19T12:00:00+02:00", "timeZone": "Asia/Jerusalem"}, + "iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z" + }, + { + "id": "m_20240326T100000Z", "status": "confirmed", "summary": "Weekly (moved)", + "recurringEventId": "m", + "originalStartTime": {"dateTime": "2024-03-26T12:00:00+02:00", "timeZone": "Asia/Jerusalem"}, + "start": {"dateTime": "2024-03-26T15:00:00+02:00", "timeZone": "Asia/Jerusalem"}, + "end": {"dateTime": "2024-03-26T16:00:00+02:00", "timeZone": "Asia/Jerusalem"}, + "iCalUID": "m@google.com", "updated": "2024-06-18T10:04:30.544Z" + } + ]"#; + + /// Exceptions share the master's iCalUID, so one file holds the series. + #[test] + fn exceptions_group_with_their_master() { + let groups = group_by_uid(parse(WITH_EXCEPTIONS)); + assert_eq!(groups.len(), 1); + let group = &groups["m@google.com"]; + assert!(group.master.is_some()); + assert_eq!(group.overrides.len(), 2); + } + + /// A deleted occurrence is an absence rather than an event. + #[test] + fn a_cancelled_occurrence_becomes_an_exdate_on_the_master() { + let groups = group_by_uid(parse(WITH_EXCEPTIONS)); + let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert"); + assert!( + ics.contains("EXDATE;TZID=Asia/Jerusalem:20240319T120000"), + "{ics}" + ); + } + + #[test] + fn a_moved_occurrence_becomes_a_recurrence_id_event() { + let groups = group_by_uid(parse(WITH_EXCEPTIONS)); + let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert"); + assert!( + ics.contains("RECURRENCE-ID;TZID=Asia/Jerusalem:20240326T120000"), + "{ics}" + ); + assert!( + ics.contains("DTSTART;TZID=Asia/Jerusalem:20240326T150000"), + "{ics}" + ); + assert!(ics.contains("SUMMARY:Weekly (moved)")); + } + + /// Whatever we render has to survive our own parser, or nothing downstream works. + #[test] + fn the_output_parses_as_icalendar() { + let groups = group_by_uid(parse(WITH_EXCEPTIONS)); + let ics = to_ical("m@google.com", &groups["m@google.com"]).expect("convert"); + let calendar = Calendar::parse(&ics).expect("output should be valid iCalendar"); + assert_eq!(calendar.uid(), Some("m@google.com")); + // Master plus the one moved occurrence; the cancelled one is an EXDATE. + assert_eq!(calendar.properties("VEVENT", "UID").count(), 2); + assert_eq!(calendar.properties("VEVENT", "RECURRENCE-ID").count(), 1); + } + + /// Converts a captured API response and checks every group survives. + /// + /// Ignored by default: it needs a real payload, and calendar data is personal, + /// so none is committed. Run against a capture with: + /// CALCALIST_GOOGLE_FIXTURE=/path/to/events.json cargo test -- --ignored + #[test] + #[ignore = "requires a captured API response"] + fn a_captured_response_converts_cleanly() { + let Ok(path) = std::env::var("CALCALIST_GOOGLE_FIXTURE") else { + panic!("set CALCALIST_GOOGLE_FIXTURE to a captured events response"); + }; + let body = std::fs::read_to_string(&path).expect("fixture should be readable"); + let response: serde_json::Value = + serde_json::from_str(&body).expect("fixture should be JSON"); + let events: Vec = + 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}" + ); + } +} diff --git a/src/google/mod.rs b/src/google/mod.rs index b87c6ea..53833cc 100644 --- a/src/google/mod.rs +++ b/src/google/mod.rs @@ -5,4 +5,6 @@ //! since March 2025. So calcalist keeps a Google calendar in step with its local //! vdir itself, doing for Google what pimsync does for CalDAV. +pub mod api; pub mod auth; +pub mod convert; diff --git a/src/main.rs b/src/main.rs index 108abfd..085a63a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,6 +138,17 @@ fn run_retarget(cli: &Cli, id: &str, to: &str, purge_old: bool) -> ExitCode { } fn print_report(report: &Report) { + for google in &report.google { + let resync = if google.full_resync { + " (Google rejected the sync cursor, so everything was refetched)" + } else { + "" + }; + println!( + "{}: pulled {} event(s), removed {}{resync}", + google.endpoint, google.written, google.deleted + ); + } if report.aggregates.is_empty() { println!("no aggregates configured"); return; diff --git a/src/mirror.rs b/src/mirror.rs index a7e2e00..2fd4e7f 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -162,7 +162,7 @@ fn address(value: &str) -> &str { } /// Escapes a value for an iCalendar TEXT property, per RFC 5545 section 3.3.11. -fn escape_text(value: &str) -> String { +pub(crate) fn escape_text(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for ch in value.chars() { match ch { diff --git a/src/retarget.rs b/src/retarget.rs index 99f8771..3b1f54c 100644 --- a/src/retarget.rs +++ b/src/retarget.rs @@ -280,17 +280,37 @@ default_sink = "src" ) } - /// A state directory with two source events already mirrored into `agg1`. - fn synced() -> tempfile::TempDir { + /// Reconciles one aggregate and records the result, without running a full + /// cycle. A full cycle would contact Google for real, which these tests have + /// no business doing — they are about what happens to local files. + fn settle(config: &Config, dir: &Path) { + let aggregate = &config.aggregates[0]; + let target = config + .endpoint(&aggregate.target) + .expect("target endpoint") + .clone(); + let state_path = dir.join(crate::state::FILE_NAME); + let mut state = State::load(&state_path).expect("state"); + sync::sync_aggregate(config, aggregate, &target, dir, &mut state, false, false) + .expect("reconcile"); + state.save(&state_path).expect("save state"); + } + + fn seed(config: &Config) -> tempfile::TempDir { let dir = tempfile::tempdir().expect("temp dir"); let src = vdir_path(dir.path(), "src"); fs::create_dir_all(&src).expect("create src vdir"); fs::write(src.join("a.ics"), event("a@example.com", "A")).expect("write"); fs::write(src.join("b.ics"), event("b@example.com", "B")).expect("write"); - sync::run(&config(), dir.path(), false, false).expect("initial sync"); + settle(config, dir.path()); dir } + /// A state directory with two source events already mirrored into `agg1`. + fn synced() -> tempfile::TempDir { + seed(&config()) + } + fn count(dir: &Path, endpoint: &str) -> usize { vdir::read(&vdir_path(dir, endpoint)) .expect("read vdir") @@ -333,12 +353,7 @@ default_sink = "src" fn purging_removes_only_this_aggregates_mirrors() { let sinkless: Config = toml::from_str(&CONFIG.replace(r#"default_sink = "src""#, "")) .expect("config should parse"); - let dir = tempfile::tempdir().expect("temp dir"); - let src = vdir_path(dir.path(), "src"); - fs::create_dir_all(&src).expect("create src vdir"); - fs::write(src.join("a.ics"), event("a@example.com", "A")).expect("write"); - fs::write(src.join("b.ics"), event("b@example.com", "B")).expect("write"); - sync::run(&sinkless, dir.path(), false, false).expect("initial sync"); + let dir = seed(&sinkless); let old = vdir_path(dir.path(), "agg1"); fs::write(old.join("mine.ics"), event("my-own@example.com", "Mine")).expect("write"); diff --git a/src/state.rs b/src/state.rs index daa9334..9033fce 100644 --- a/src/state.rs +++ b/src/state.rs @@ -48,6 +48,20 @@ pub struct State { pub version: u32, #[serde(default)] pub aggregates: BTreeMap, + /// Per-endpoint bookkeeping for the Google leg, which calcalist syncs itself. + #[serde(default)] + pub google: BTreeMap, +} + +/// What is remembered between cycles for one Google calendar. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +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. + #[serde(default)] + pub events: BTreeMap, } impl Default for State { @@ -55,6 +69,7 @@ impl Default for State { State { version: STATE_VERSION, aggregates: BTreeMap::new(), + google: BTreeMap::new(), } } } diff --git a/src/sync.rs b/src/sync.rs index b1f20e3..fba66b8 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -11,6 +11,8 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use crate::config::{Aggregate, Config, Endpoint, EndpointKind}; +use crate::google::api::{self, ApiError}; +use crate::google::auth::{self, AuthError}; use crate::pimsync::{self, PimsyncError}; use crate::reconcile::{self, Action, Conflict, Policy, ReconcileError, Skipped, SourceView}; use crate::state::{State, Target}; @@ -24,6 +26,18 @@ pub enum SyncError { State(#[from] crate::state::StateError), #[error(transparent)] Pimsync(#[from] PimsyncError), + #[error("google endpoint `{endpoint}`: {source}")] + Google { + endpoint: String, + #[source] + source: ApiError, + }, + #[error("google endpoint `{endpoint}`: {source}")] + GoogleAuth { + endpoint: String, + #[source] + source: AuthError, + }, #[error("could not create {path}: {source}")] Prepare { path: PathBuf, @@ -54,9 +68,18 @@ pub enum SyncError { #[derive(Debug, Default)] pub struct Report { pub aggregates: Vec, + pub google: Vec, pub dry_run: bool, } +#[derive(Debug)] +pub struct GoogleReport { + pub endpoint: String, + pub written: usize, + pub deleted: usize, + pub full_resync: bool, +} + #[derive(Debug)] pub struct AggregateReport { pub id: String, @@ -97,6 +120,9 @@ pub fn run( if let Some(path) = &pimsync_config { pimsync::sync(path)?; } + if !dry_run { + report.google = pull_google(config, state_dir, &mut state)?; + } for aggregate in &config.aggregates { let target = resolve(config, aggregate, &aggregate.target)?; @@ -117,6 +143,51 @@ pub fn run( Ok(report) } +/// Brings every Google endpoint's vdir into step with its remote calendar. +/// +/// This is the Google equivalent of the pimsync pull: pimsync cannot reach +/// Google at all, so calcalist does this leg itself. +fn pull_google( + config: &Config, + state_dir: &Path, + state: &mut State, +) -> Result, SyncError> { + let mut reports = Vec::new(); + 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 pulled = api::pull(&client, &dir, entry).map_err(|source| SyncError::Google { + endpoint: endpoint.id.clone(), + source, + })?; + reports.push(GoogleReport { + endpoint: endpoint.id.clone(), + written: pulled.written, + deleted: pulled.deleted, + full_resync: pulled.full_resync, + }); + } + Ok(reports) +} + /// 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| {