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