Pull Google calendars into their local vdirs

The Google equivalent of the pimsync pull, which pimsync cannot do: incremental
fetch by syncToken, converted to iCalendar and written into the endpoint's vdir.
A cursor Google no longer accepts comes back as 410, which means start again
rather than something broke, so that case refetches instead of failing.

The conversion was written against what the API actually returns, having probed
a real calendar first. Three findings shaped it:

- A recurring event's exceptions carry the same iCalUID as their master, so a
  series belongs in one file, which is exactly the vdir convention.
- start.dateTime is an absolute instant while start.timeZone names the zone the
  recurrence expands in, and the two need not agree: a real event reads
  2023-10-31T13:00:00+02:00 with timeZone Asia/Karachi, which is +05:00.
  Emitting the instant under that TZID unconverted would move it three hours, so
  the instant is converted into its zone. That needs a timezone database, hence
  jiff.
- A deleted occurrence arrives as an override with status cancelled. That is an
  absence rather than an event, so it becomes an EXDATE on the master; moved
  occurrences become RECURRENCE-ID events.

Timed values are written in UTC unless the event recurs. Only a recurrence needs
a zone to expand in, and confining TZID to those events limits how far we depend
on clients tolerating a TZID with no VTIMEZONE alongside it.

Verified against a live calendar: 30 real events, including a weekly series with
both a cancelled and several moved occurrences, converted and re-parsed intact.

The retarget tests no longer run a full cycle. They had used Google endpoints as
inert local stand-ins, which stopped being true the moment sync learned to
contact Google; they now reconcile directly.

115 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 13:13:26 +03:00
parent dd984e150a
commit edb16204fa
11 changed files with 971 additions and 12 deletions

108
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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:

245
src/google/api.rs Normal file
View file

@ -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<Event>,
pub next_sync_token: Option<String>,
/// 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<Changes, ApiError> {
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<Changes, ApiError> {
let mut changes = Changes::default();
let mut page_token: Option<String> = 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<Event> = 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<Option<Event>, 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<String, ApiError> {
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<PullReport, ApiError> {
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
}

490
src/google/convert.rs Normal file
View file

@ -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<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub start: Option<EventTime>,
#[serde(default)]
pub end: Option<EventTime>,
/// Raw iCalendar recurrence lines — RRULE, EXDATE, RDATE — passed through.
#[serde(default)]
pub recurrence: Option<Vec<String>>,
#[serde(default)]
pub recurring_event_id: Option<String>,
#[serde(default)]
pub original_start_time: Option<EventTime>,
#[serde(default)]
pub transparency: Option<String>,
#[serde(default)]
pub sequence: Option<i64>,
#[serde(default)]
pub updated: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventTime {
/// Set for all-day events, as `YYYY-MM-DD`.
#[serde(default)]
pub date: Option<String>,
/// An absolute instant, RFC 3339.
#[serde(default)]
pub date_time: Option<String>,
/// The zone the recurrence expands in — not necessarily the offset above.
#[serde(default)]
pub time_zone: Option<String>,
}
impl Event {
pub fn is_cancelled(&self) -> bool {
self.status.as_deref() == Some("cancelled")
}
pub fn is_override(&self) -> bool {
self.recurring_event_id.is_some()
}
fn is_recurring(&self) -> bool {
self.recurrence
.as_ref()
.is_some_and(|lines| !lines.is_empty())
}
}
/// A master event together with its exceptions, all sharing one UID.
#[derive(Debug, Default)]
pub struct Group {
pub master: Option<Event>,
pub overrides: Vec<Event>,
}
/// Groups a page of events by the UID they will be stored under.
pub fn group_by_uid(events: Vec<Event>) -> BTreeMap<String, Group> {
let mut groups: BTreeMap<String, Group> = BTreeMap::new();
for event in events {
let Some(uid) = event.ical_uid.clone() else {
continue;
};
let group = groups.entry(uid).or_default();
if event.is_override() {
group.overrides.push(event);
} else {
group.master = Some(event);
}
}
groups
}
/// Renders a group as one iCalendar object.
pub fn to_ical(uid: &str, group: &Group) -> Result<String, ConvertError> {
let Some(master) = &group.master else {
// Exceptions arrived without their master, which happens on an
// incremental sync. The caller merges these into the stored file.
return Err(ConvertError::NoUid {
id: uid.to_string(),
});
};
let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calcalist//EN\r\n");
out.push_str(&render_event(uid, master, None)?);
// A deleted occurrence is an absence, not an event: it belongs on the master
// as an EXDATE.
for cancelled in group.overrides.iter().filter(|event| event.is_cancelled()) {
if let Some(start) = &cancelled.original_start_time {
let line = time_property("EXDATE", start, &cancelled.id, master.is_recurring())?;
// Insert before the master's END:VEVENT.
let end = out.rfind("END:VEVENT\r\n").unwrap_or(out.len());
out.insert_str(end, &format!("{line}\r\n"));
}
}
for moved in group.overrides.iter().filter(|event| !event.is_cancelled()) {
out.push_str(&render_event(
uid,
moved,
moved.original_start_time.as_ref(),
)?);
}
out.push_str("END:VCALENDAR\r\n");
Ok(out)
}
fn render_event(
uid: &str,
event: &Event,
recurrence_id: Option<&EventTime>,
) -> Result<String, ConvertError> {
let recurring = event.is_recurring() || recurrence_id.is_some();
let mut out = String::from("BEGIN:VEVENT\r\n");
out.push_str(&format!("UID:{uid}\r\n"));
out.push_str(&format!("DTSTAMP:{}\r\n", stamp(event)?));
if let Some(original) = recurrence_id {
out.push_str(&time_property(
"RECURRENCE-ID",
original,
&event.id,
recurring,
)?);
out.push_str("\r\n");
}
let start = event.start.as_ref().ok_or_else(|| ConvertError::NoStart {
id: event.id.clone(),
})?;
out.push_str(&time_property("DTSTART", start, &event.id, recurring)?);
out.push_str("\r\n");
if let Some(end) = &event.end {
out.push_str(&time_property("DTEND", end, &event.id, recurring)?);
out.push_str("\r\n");
}
for line in event.recurrence.iter().flatten() {
out.push_str(line);
out.push_str("\r\n");
}
for (name, value) in [
("SUMMARY", event.summary.as_deref()),
("DESCRIPTION", event.description.as_deref()),
("LOCATION", event.location.as_deref()),
] {
if let Some(value) = value {
out.push_str(&format!("{name}:{}\r\n", escape_text(value)));
}
}
if let Some(transparency) = &event.transparency {
let value = if transparency == "transparent" {
"TRANSPARENT"
} else {
"OPAQUE"
};
out.push_str(&format!("TRANSP:{value}\r\n"));
}
if event.status.as_deref() == Some("tentative") {
out.push_str("STATUS:TENTATIVE\r\n");
}
if let Some(sequence) = event.sequence {
out.push_str(&format!("SEQUENCE:{sequence}\r\n"));
}
out.push_str("END:VEVENT\r\n");
Ok(out)
}
/// Renders one date-or-time property.
///
/// Timed values are written in UTC unless the event recurs. A recurrence has to
/// expand in its own zone or it drifts by an hour across a daylight-saving
/// change, so those carry a TZID — and the instant is converted into that zone
/// first, since Google's offset and its `timeZone` may disagree.
fn time_property(
name: &str,
time: &EventTime,
id: &str,
recurring: bool,
) -> Result<String, ConvertError> {
if let Some(date) = &time.date {
return Ok(format!("{name};VALUE=DATE:{}", date.replace('-', "")));
}
let value = time
.date_time
.as_deref()
.ok_or_else(|| ConvertError::NoStart { id: id.to_string() })?;
let instant: Timestamp = value.parse().map_err(|source| ConvertError::BadTime {
id: id.to_string(),
field: "dateTime",
value: value.to_string(),
source,
})?;
let zone = time
.time_zone
.as_deref()
.filter(|_| recurring)
.and_then(|name| TimeZone::get(name).ok().map(|zone| (name, zone)));
match zone {
Some((zone_name, zone)) => {
let local = instant.to_zoned(zone);
Ok(format!(
"{name};TZID={zone_name}:{}",
local.strftime("%Y%m%dT%H%M%S")
))
}
None => Ok(format!(
"{name}:{}",
instant.to_zoned(TimeZone::UTC).strftime("%Y%m%dT%H%M%SZ")
)),
}
}
/// DTSTAMP, taken from the event's last update where Google supplies one.
fn stamp(event: &Event) -> Result<String, ConvertError> {
let Some(updated) = &event.updated else {
return Ok(Timestamp::now().strftime("%Y%m%dT%H%M%SZ").to_string());
};
let instant: Timestamp = updated.parse().map_err(|source| ConvertError::BadTime {
id: event.id.clone(),
field: "updated",
value: updated.clone(),
source,
})?;
Ok(instant.strftime("%Y%m%dT%H%M%SZ").to_string())
}
#[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")
}
/// 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<Event> =
serde_json::from_value(response["items"].clone()).expect("items should deserialise");
let total = events.len();
let groups = group_by_uid(events);
let mut converted = 0;
for (uid, group) in &groups {
if group.master.is_none() {
// An exception whose master is outside this page; the caller
// merges those into the stored file rather than rendering alone.
continue;
}
let ics = to_ical(uid, group).unwrap_or_else(|error| panic!("{uid}: {error}"));
Calendar::parse(&ics)
.unwrap_or_else(|error| panic!("{uid} produced invalid iCal: {error}\n{ics}"));
converted += 1;
}
println!(
"{total} event(s) in {} group(s); {converted} rendered",
groups.len()
);
assert!(converted > 0, "nothing was converted");
}
#[test]
fn text_values_are_escaped() {
let json = r#"[{
"id": "x", "status": "confirmed",
"summary": "Lunch, then talk; bring notes",
"start": {"date": "2026-02-02"}, "end": {"date": "2026-02-03"},
"iCalUID": "x@google.com", "updated": "2026-01-01T00:00:00.000Z"
}]"#;
let groups = group_by_uid(parse(json));
let ics = to_ical("x@google.com", &groups["x@google.com"]).expect("convert");
assert!(
ics.contains(r"SUMMARY:Lunch\, then talk\; bring notes"),
"{ics}"
);
}
}

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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");

View file

@ -48,6 +48,20 @@ pub struct State {
pub version: u32,
#[serde(default)]
pub aggregates: BTreeMap<String, AggregateState>,
/// Per-endpoint bookkeeping for the Google leg, which calcalist syncs itself.
#[serde(default)]
pub google: BTreeMap<String, GoogleState>,
}
/// 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<String>,
/// Google's own event id for each stored UID, needed to update or delete it.
#[serde(default)]
pub events: BTreeMap<String, String>,
}
impl Default for State {
@ -55,6 +69,7 @@ impl Default for State {
State {
version: STATE_VERSION,
aggregates: BTreeMap::new(),
google: BTreeMap::new(),
}
}
}

View file

@ -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<AggregateReport>,
pub google: Vec<GoogleReport>,
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<Vec<GoogleReport>, 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| {