git.lucas.co / cce-calendar
calendar
git clone https://git.lucas.co/cce-calendar.git

commit9e63da384cff0a5f0b8326cf9050cb9cd67cc7d6
parentf7c82aa468
authorLucas Galante <[email protected]>
date2026-09-08 10:47
Calendar write-back: typed events reach the server, edits and deletes follow

The mirror gains a write half. A sync-state.json sidecar records each
non-recurring event's (date, time, title, etag, URL) as last synced, and
cce-calendar-sync merges three-way: a record with no source (typed in the
app) is created on the default calendar - `push-to "icloud"|"google"|"none"
[calendar="Name"]` in the app's config.kdl, iCloud when unset and present -
and comes back carrying uid and source; a tracked event missing from the
file is deleted on the server; one whose date, time or title moved is
updated there, local winning over a simultaneous remote edit. Instances of
recurring events are mirrored with a `recurring` flag and never written -
the file cannot say "just this one" - and the app refuses to delete them
with a hint instead. Guards: a missing events.json re-imports rather than
deletes; a run that would delete most tracked events refuses without
--force-deletes.

CalDAV updates patch the fetched iCalendar (SUMMARY, DTSTART, DTEND; a
DURATION is dropped) so alarms and Apple's properties survive; the REPORT
now asks for getetag and keeps each resource's href. Google writes are
field-level PATCHes with If-Match on the primary calendar and need the
calendar.events scope. The app re-reads events.json once a second when
its mtime changes, so pushed identities and phone-side changes appear
without a relaunch.

Verified against iCloud: create, edit and delete of a test event each
round-tripped and converged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/bin/sync.rs | 1040 +++++++++++++++++++++++++++++++++++++++++++++----------
 src/lib.rs      |  140 +++++++-
 src/main.rs     |   57 ++-
 3 files changed, 1041 insertions(+), 196 deletions(-)

diff --git a/src/bin/sync.rs b/src/bin/sync.rs
index c405820..a3657cf 100644
--- a/src/bin/sync.rs
+++ b/src/bin/sync.rs
@@ -1,4 +1,4 @@
-//! `cce-calendar-sync` — read-only mirror of remote calendars into
+//! `cce-calendar-sync` — two-way sync between remote calendars and
 //! cce-calendar's `events.json`.
 //!
 //! Accounts come from the same `accounts.json` cce-mail reads (owned by
@@ -8,33 +8,44 @@
 //!   service — an Apple app-specific password is valid for CalDAV as well as
 //!   IMAP, so the credential that fetches mail fetches the calendar too.
 //! - **Google**, over the Calendar REST API, with the OAuth tokens the
-//!   settings app's Google sign-in stores (it requests `calendar.readonly`).
-//!   Google's CalDAV endpoint refuses app passwords, hence the API. The
-//!   access token is refreshed in memory each run and never written back:
-//!   accounts.json has enough writers already (cce-system-interface,
-//!   cce-mail), and a refresh grant costs one round trip.
+//!   settings app's Google sign-in stores (`calendar.readonly` to read,
+//!   `calendar.events` to write). Google's CalDAV endpoint refuses app
+//!   passwords, hence the API. The access token is refreshed in memory each
+//!   run and never written back: accounts.json has enough writers already.
 //!
-//! Each run replaces exactly the records whose `source` matches the account
-//! being synced ("icloud:<email>" / "google:<email>"); hand-entered events
-//! (no `source`) and other accounts' records pass through untouched. A
-//! record deleted in the app therefore reappears on the next tick — this
-//! mirror is read-only by design, and the server is the source of truth for
-//! what it owns.
+//! Reading is a mirror: each run replaces the records whose `source` matches
+//! the account ("icloud:<email>" / "google:<email>"), with recurrences
+//! expanded server-side (CalDAV `<C:expand>`, Google `singleEvents=true`)
+//! and marked `recurring`. Writing is a three-way merge against
+//! `sync-state.json`, the last-synced (date, time, title, etag) per
+//! NON-recurring event: a record typed in the app (no `source`) is created
+//! on the default calendar (`push-to` in the app's config.kdl; iCloud when
+//! unset and such an account exists) and comes back carrying its identity;
+//! a tracked event missing from the file is deleted on the server; one whose
+//! date, time or title differs from the state is updated there (local wins
+//! over a simultaneous remote edit; the next tick reconciles). Instances of
+//! recurring events are never written: the mirror cannot say "just this
+//! one", so a deleted instance simply reappears. Guards: a missing
+//! events.json re-imports rather than deletes, and a run that would delete
+//! most tracked events (>5 and >50%) refuses without `--force-deletes`.
 //!
-//! Recurring events are expanded server-side (CalDAV `<C:expand>`, Google
-//! `singleEvents=true`), which also normalizes times. If a CalDAV REPORT
-//! rejects expansion, the query is retried plain and RRULE-carrying events
-//! are skipped with a log line rather than shown on the wrong day.
+//! CalDAV updates PATCH the fetched iCalendar (SUMMARY, DTSTART, DTEND)
+//! rather than rebuilding it, so alarms and Apple's own properties survive;
+//! Google updates are field-level PATCHes with If-Match.
 //!
-//! Usage: `cce-calendar-sync [--dry-run]`. Driven by cce-calendar-sync.timer;
-//! harmless to run by hand. Exits nonzero if any account failed (the timer
-//! just tries again next tick); other accounts' results are still written.
+//! Usage: `cce-calendar-sync [--dry-run] [--force-deletes]`. Driven by
+//! cce-calendar-sync.timer; harmless to run by hand. Exits nonzero if any
+//! account failed (the timer just tries again next tick); other accounts'
+//! results are still written.
 
-use std::collections::BTreeSet;
+use std::collections::{BTreeMap, BTreeSet};
 use std::str::FromStr;
 
-use chrono::{DateTime, Days, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
-use cce_calendar::{load_records, save_records, EventRecord};
+use cce_calendar::{
+    data_path, load_records, load_sync_state, push_target_config, save_records, save_sync_state,
+    sort_records, EventRecord, PushTarget, SyncState, SyncedEvent,
+};
+use chrono::{DateTime, Days, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
 
 const CALDAV_ROOT: &str = "https://caldav.icloud.com/";
 /// Sync window around today. Wide enough forward that "next spring" plans
@@ -44,12 +55,16 @@ const FUTURE_DAYS: u64 = 400;
 /// An all-day event spanning more than this is almost certainly bad data
 /// (a botched DTEND); clamp rather than flood two months of cells.
 const MAX_ALLDAY_SPAN: u64 = 62;
+/// A timed event typed in the app has no end; it is created an hour long.
+const DEFAULT_DURATION_MIN: i64 = 60;
 
 const CALDAV_NS: &str = "urn:ietf:params:xml:ns:caldav";
 
 fn main() {
     env_logger::init();
-    let dry_run = std::env::args().any(|a| a == "--dry-run");
+    let args: Vec<String> = std::env::args().collect();
+    let dry_run = args.iter().any(|a| a == "--dry-run");
+    let force_deletes = args.iter().any(|a| a == "--force-deletes");
 
     let (icloud, google) = match (icloud_accounts(), google_accounts()) {
         (Ok(i), Ok(g)) => (i, g),
@@ -69,63 +84,423 @@ fn main() {
         today.checked_add_days(Days::new(FUTURE_DAYS)).unwrap_or(today),
     );
 
+    // Typed events go to one calendar; the account kind that owns it.
+    let push = push_target_config().unwrap_or_else(|| PushTarget {
+        kind: if !icloud.is_empty() { "icloud" } else { "google" }.to_string(),
+        calendar: None,
+    });
+
+    let had_file = data_path().exists();
+    let mut records = match load_records() {
+        Ok(r) => r,
+        Err(e) => {
+            // Refuse to rewrite a file we could not read — that would
+            // silently drop every hand-entered event.
+            log::error!("events.json unreadable, not writing: {e}");
+            std::process::exit(1);
+        }
+    };
+    let mut state = match load_sync_state() {
+        Ok(s) => s,
+        Err(e) => {
+            log::error!("sync-state.json unreadable: {e}");
+            std::process::exit(1);
+        }
+    };
+    if !had_file && !state.events.is_empty() {
+        // The file is gone (fresh clone, deleted). Re-import rather than
+        // reading absence as "delete everything on the server".
+        log::warn!("events.json missing; discarding sync state and re-importing");
+        state = SyncState::default();
+    }
+
     let mut failed = false;
-    let mut synced: Vec<(String, Vec<EventRecord>)> = Vec::new();
-    for acc in &icloud {
-        let source = format!("icloud:{}", acc.email);
-        match sync_account(acc, window) {
-            Ok(records) => {
-                log::info!("{}: {} event records", acc.email, records.len());
-                synced.push((source, records));
-            }
+    let mut synced_any = false;
+    let backends: Vec<Backend> = icloud
+        .into_iter()
+        .map(Backend::ICloud)
+        .chain(google.into_iter().map(Backend::Google))
+        .collect();
+    for (i, backend) in backends.iter().enumerate() {
+        let push_here = push.kind == backend.kind()
+            && backends.iter().position(|b| b.kind() == push.kind) == Some(i);
+        match sync_source(backend, window, &push, push_here, &mut records, &mut state, dry_run, force_deletes)
+        {
+            Ok(()) => synced_any = true,
             Err(e) => {
-                log::error!("{}: sync failed, keeping existing records: {e}", acc.email);
+                log::error!("{}: sync failed, keeping existing records: {e}", backend.email());
                 failed = true;
             }
         }
     }
-    for acc in &google {
-        let source = format!("google:{}", acc.email);
-        match sync_google(acc, window) {
-            Ok(records) => {
-                log::info!("{}: {} event records", acc.email, records.len());
-                synced.push((source, records));
+
+    if synced_any && !dry_run {
+        sort_records(&mut records);
+        if let Err(e) = save_records(&records) {
+            log::error!("saving events.json failed: {e}");
+            failed = true;
+        }
+        if let Err(e) = save_sync_state(&state) {
+            log::error!("saving sync-state.json failed: {e}");
+            failed = true;
+        }
+    }
+    if failed {
+        std::process::exit(1);
+    }
+}
+
+enum Backend {
+    ICloud(Account),
+    Google(GoogleAccount),
+}
+
+impl Backend {
+    fn email(&self) -> &str {
+        match self {
+            Backend::ICloud(a) => &a.email,
+            Backend::Google(a) => &a.email,
+        }
+    }
+    fn kind(&self) -> &'static str {
+        match self {
+            Backend::ICloud(_) => "icloud",
+            Backend::Google(_) => "google",
+        }
+    }
+    fn source(&self) -> String {
+        format!("{}:{}", self.kind(), self.email())
+    }
+}
+
+// ── Remote model (both backends produce it) ───────────────────────────────
+
+struct RemoteCalendar {
+    /// Where a new event is created (CalDAV collection URL / Google
+    /// calendar id).
+    target: String,
+    name: String,
+    primary: bool,
+}
+
+struct RemoteEvent {
+    url: reqwest::Url,
+    etag: String,
+    recurring: bool,
+    /// Every dated instance in the window, ready for the file.
+    instances: Vec<EventRecord>,
+    /// Unfolded logical lines of the resource (CalDAV only), for
+    /// patch-and-PUT.
+    lines: Vec<String>,
+}
+
+struct Remote {
+    calendars: Vec<RemoteCalendar>,
+    events: BTreeMap<String, RemoteEvent>,
+}
+
+// ── One account ───────────────────────────────────────────────────────────
+
+#[allow(clippy::too_many_arguments)]
+fn sync_source(
+    backend: &Backend,
+    window: (NaiveDate, NaiveDate),
+    push: &PushTarget,
+    push_here: bool,
+    records: &mut Vec<EventRecord>,
+    state: &mut SyncState,
+    dry_run: bool,
+    force_deletes: bool,
+) -> Result<(), String> {
+    let client = reqwest::blocking::Client::builder()
+        .timeout(std::time::Duration::from_secs(60))
+        .build()
+        .map_err(|e| e.to_string())?;
+    let source = backend.source();
+    let session = match backend {
+        Backend::ICloud(_) => Session::ICloud,
+        Backend::Google(acc) => Session::Google(google_access_token(&client, acc)?),
+    };
+    let ops = Ops { client: &client, backend, session: &session };
+    let mut remote = ops.fetch(window, &source)?;
+    log::info!(
+        "{}: {} calendar(s), {} event(s) in window",
+        backend.email(),
+        remote.calendars.len(),
+        remote.events.len()
+    );
+
+    // ── Plan ───────────────────────────────────────────────────────────
+    let local_by_uid: BTreeMap<&str, &EventRecord> = records
+        .iter()
+        .filter(|r| r.source.as_deref() == Some(&source))
+        .filter_map(|r| r.uid.as_deref().map(|u| (u, r)))
+        .collect();
+    let mut push_deletes: Vec<String> = Vec::new();
+    let mut push_updates: Vec<(String, EventRecord)> = Vec::new();
+    let mut forget: Vec<String> = Vec::new();
+    for (uid, s) in state.events.iter().filter(|(_, s)| s.source == source) {
+        match remote.events.get(uid) {
+            None => forget.push(uid.clone()),
+            Some(r) if r.recurring => forget.push(uid.clone()),
+            Some(_) => match local_by_uid.get(uid.as_str()) {
+                None => push_deletes.push(uid.clone()),
+                Some(l) if !s.matches(l) => push_updates.push((uid.clone(), (*l).clone())),
+                Some(_) => {}
+            },
+        }
+    }
+    let push_creates: Vec<usize> = if push_here {
+        records
+            .iter()
+            .enumerate()
+            .filter(|(_, r)| r.source.is_none() && r.uid.is_none())
+            .map(|(i, _)| i)
+            .collect()
+    } else {
+        Vec::new()
+    };
+
+    let tracked = state.events.values().filter(|s| s.source == source).count();
+    if !force_deletes && push_deletes.len() > 5 && push_deletes.len() * 2 > tracked {
+        return Err(format!(
+            "refusing to delete {} of {tracked} tracked events on the server — if they were \
+             really removed on purpose, run cce-calendar-sync --force-deletes",
+            push_deletes.len()
+        ));
+    }
+    log::info!(
+        "{}: push {} new / {} changed / {} deleted",
+        backend.email(),
+        push_creates.len(),
+        push_updates.len(),
+        push_deletes.len()
+    );
+    let target = if push_here { Some(ops.pick_target(&remote.calendars, push)?) } else { None };
+    if let Some(t) = &target {
+        if !push_creates.is_empty() {
+            log::info!("{}: new events go to {}", backend.email(), t.name);
+        }
+    }
+    if dry_run {
+        for i in &push_creates {
+            let r = &records[*i];
+            println!("push new:    {} {} {}", r.date, r.time.as_deref().unwrap_or("-----"), r.title);
+        }
+        for (uid, r) in &push_updates {
+            println!("push change: {} {} {} ({uid})", r.date, r.time.as_deref().unwrap_or("-----"), r.title);
+        }
+        for uid in &push_deletes {
+            println!("push delete: {} ({uid})", state.events[uid].title);
+        }
+        let mirrored: usize = remote.events.values().map(|e| e.instances.len()).sum();
+        println!("{source}: {mirrored} record(s) mirrored");
+        return Ok(());
+    }
+
+    // ── Server side ────────────────────────────────────────────────────
+    for uid in &push_deletes {
+        let s = &state.events[uid];
+        let url = reqwest::Url::parse(&s.url).map_err(|e| e.to_string())?;
+        match ops.delete(&url, &s.etag) {
+            Ok(()) => {
+                state.events.remove(uid);
+                remote.events.remove(uid);
             }
-            Err(e) => {
-                log::error!("{}: sync failed, keeping existing records: {e}", acc.email);
-                failed = true;
+            Err(e) => log::warn!("push delete {uid} failed (will retry next tick): {e}"),
+        }
+    }
+    for (uid, local) in &push_updates {
+        let Some(r) = remote.events.get_mut(uid) else { continue };
+        match ops.update(r, local) {
+            Ok(etag) => {
+                r.etag = etag.clone();
+                r.instances = vec![EventRecord {
+                    uid: Some(uid.clone()),
+                    source: Some(source.clone()),
+                    recurring: false,
+                    ..local.clone()
+                }];
+                state.events.insert(uid.clone(), synced(&source, &r.url, etag, local));
+            }
+            Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
+        }
+    }
+    let mut consumed: BTreeSet<usize> = BTreeSet::new();
+    if let Some(t) = &target {
+        for i in &push_creates {
+            let local = &records[*i];
+            match ops.create(t, local) {
+                Ok((uid, url, etag)) => {
+                    state.events.insert(uid.clone(), synced(&source, &url, etag.clone(), local));
+                    remote.events.insert(
+                        uid.clone(),
+                        RemoteEvent {
+                            url,
+                            etag,
+                            recurring: false,
+                            instances: vec![EventRecord {
+                                uid: Some(uid),
+                                source: Some(source.clone()),
+                                recurring: false,
+                                ..local.clone()
+                            }],
+                            lines: Vec::new(),
+                        },
+                    );
+                    consumed.insert(*i);
+                }
+                Err(e) => log::warn!("push create {:?} failed (will retry next tick): {e}", local.title),
             }
         }
     }
 
-    if !synced.is_empty() {
-        let existing = match load_records() {
-            Ok(r) => r,
-            Err(e) => {
-                // Refuse to rewrite a file we could not read — that would
-                // silently drop every hand-entered event.
-                log::error!("events.json unreadable, not writing: {e}");
-                std::process::exit(1);
+    // ── State: every non-recurring event the server now holds ─────────
+    for uid in &forget {
+        state.events.remove(uid);
+    }
+    for (uid, r) in &remote.events {
+        if r.recurring {
+            continue;
+        }
+        if let Some(first) = r.instances.first() {
+            state.events.insert(uid.clone(), synced(&source, &r.url, r.etag.clone(), first));
+        }
+    }
+
+    // ── File: this source's records are the fresh mirror ──────────────
+    let mut idx = 0;
+    records.retain(|r| {
+        let keep = r.source.as_deref() != Some(&source) && !consumed.contains(&idx);
+        idx += 1;
+        keep
+    });
+    for r in remote.events.values() {
+        records.extend(r.instances.iter().cloned());
+    }
+    Ok(())
+}
+
+fn synced(source: &str, url: &reqwest::Url, etag: String, r: &EventRecord) -> SyncedEvent {
+    SyncedEvent {
+        source: source.to_string(),
+        url: url.to_string(),
+        etag,
+        date: r.date.clone(),
+        time: r.time.clone(),
+        title: r.title.clone(),
+    }
+}
+
+/// Per-run credentials the requests need beyond the account itself.
+enum Session {
+    ICloud,
+    Google(String),
+}
+
+/// The backend operations, bundled so the pass reads the same either way.
+struct Ops<'a> {
+    client: &'a reqwest::blocking::Client,
+    backend: &'a Backend,
+    session: &'a Session,
+}
+
+impl Ops<'_> {
+    fn token(&self) -> &str {
+        match self.session {
+            Session::Google(t) => t,
+            Session::ICloud => "",
+        }
+    }
+
+    fn fetch(&self, window: (NaiveDate, NaiveDate), source: &str) -> Result<Remote, String> {
+        match self.backend {
+            Backend::ICloud(acc) => fetch_icloud(self.client, acc, window, source),
+            Backend::Google(acc) => fetch_google(self.client, self.token(), acc, window, source),
+        }
+    }
+
+    /// The calendar typed events are created on: the configured name, else
+    /// the account's primary (Google) or first (iCloud) event calendar.
+    fn pick_target<'c>(
+        &self,
+        calendars: &'c [RemoteCalendar],
+        push: &PushTarget,
+    ) -> Result<&'c RemoteCalendar, String> {
+        if let Some(name) = &push.calendar {
+            return calendars
+                .iter()
+                .find(|c| c.name.eq_ignore_ascii_case(name))
+                .ok_or_else(|| format!("push-to calendar {name:?} not found on this account"));
+        }
+        calendars
+            .iter()
+            .find(|c| c.primary)
+            .or_else(|| calendars.first())
+            .ok_or_else(|| "no writable calendar on this account".to_string())
+    }
+
+    fn create(
+        &self,
+        target: &RemoteCalendar,
+        r: &EventRecord,
+    ) -> Result<(String, reqwest::Url, String), String> {
+        match self.backend {
+            Backend::ICloud(acc) => {
+                let uid = new_uid();
+                let base = reqwest::Url::parse(&target.target).map_err(|e| e.to_string())?;
+                let url = base.join(&format!("{uid}.ics")).map_err(|e| e.to_string())?;
+                let etag = put_ics(self.client, acc, &url, &new_vevent(&uid, r)?, None)?;
+                Ok((uid, url, etag))
             }
-        };
-        let merged = merge(existing, &synced);
-        if dry_run {
-            for (source, records) in &synced {
-                for r in records {
-                    println!("{source}: {} {} {}", r.date, r.time.as_deref().unwrap_or("-----"), r.title);
-                }
+            Backend::Google(_) => google_create(self.client, self.token(), &target.target, r),
+        }
+    }
+
+    fn update(&self, remote: &RemoteEvent, r: &EventRecord) -> Result<String, String> {
+        match self.backend {
+            Backend::ICloud(acc) => {
+                let body = patch_vevent(&remote.lines, r)?;
+                put_ics(self.client, acc, &remote.url, &body, Some(&remote.etag))
             }
-            println!("dry run: {} records total after merge, not written", merged.len());
-        } else if let Err(e) = save_records(&merged) {
-            log::error!("saving events.json failed: {e}");
-            failed = true;
+            Backend::Google(_) => google_update(self.client, self.token(), &remote.url, &remote.etag, r),
         }
     }
-    if failed {
-        std::process::exit(1);
+
+    fn delete(&self, url: &reqwest::Url, etag: &str) -> Result<(), String> {
+        match self.backend {
+            Backend::ICloud(acc) => delete_ics(self.client, acc, url, etag),
+            Backend::Google(_) => google_delete(self.client, self.token(), url),
+        }
     }
 }
 
+// ── Time helpers ──────────────────────────────────────────────────────────
+
+fn parse_record_time(r: &EventRecord) -> Result<(NaiveDate, Option<(u32, u32)>), String> {
+    let date = r.date.parse::<NaiveDate>().map_err(|e| format!("bad date {:?}: {e}", r.date))?;
+    let time = match &r.time {
+        None => None,
+        Some(t) => {
+            let (h, m) = t.split_once(':').ok_or_else(|| format!("bad time {t:?}"))?;
+            Some((h.parse().map_err(|_| format!("bad time {t:?}"))?, m.parse().map_err(|_| format!("bad time {t:?}"))?))
+        }
+    };
+    Ok((date, time))
+}
+
+/// A record's start (and default end) as local wall-clock instants.
+fn record_span(r: &EventRecord) -> Result<(DateTime<Local>, DateTime<Local>), String> {
+    let (date, time) = parse_record_time(r)?;
+    let (h, m) = time.unwrap_or((0, 0));
+    let ndt = date.and_hms_opt(h, m, 0).ok_or("bad time")?;
+    let start = Local
+        .from_local_datetime(&ndt)
+        .earliest()
+        .ok_or_else(|| format!("{ndt} does not exist in the local timezone"))?;
+    Ok((start, start + Duration::minutes(DEFAULT_DURATION_MIN)))
+}
+
 // ── Accounts ──────────────────────────────────────────────────────────────
 
 struct Account {
@@ -277,67 +652,80 @@ fn google_access_token(
         .ok_or_else(|| "token refresh: no access_token in response".to_string())
 }
 
-fn google_get(
+fn google_call(
     client: &reqwest::blocking::Client,
     token: &str,
+    method: reqwest::Method,
     url: &str,
     query: &[(&str, &str)],
+    body: Option<&serde_json::Value>,
+    if_match: Option<&str>,
 ) -> Result<serde_json::Value, String> {
-    let resp = client
-        .get(url)
-        .bearer_auth(token)
-        .query(query)
-        .send()
-        .map_err(|e| format!("GET {url}: {e}"))?;
+    let mut req = client.request(method.clone(), url).bearer_auth(token).query(query);
+    if let Some(b) = body {
+        req = req.json(b);
+    }
+    if let Some(e) = if_match.filter(|e| !e.is_empty()) {
+        req = req.header("If-Match", e);
+    }
+    let resp = req.send().map_err(|e| format!("{method} {url}: {e}"))?;
     let status = resp.status();
-    let body: serde_json::Value = resp.json().map_err(|e| format!("GET {url}: {e}"))?;
+    if status == reqwest::StatusCode::NO_CONTENT {
+        return Ok(serde_json::Value::Null);
+    }
+    let text = resp.text().map_err(|e| format!("{method} {url}: {e}"))?;
     if !status.is_success() {
-        return Err(format!("GET {url}: HTTP {status} {body}"));
+        return Err(format!("{method} {url}: HTTP {status} {text}"));
     }
-    Ok(body)
+    if text.trim().is_empty() {
+        return Ok(serde_json::Value::Null);
+    }
+    serde_json::from_str(&text).map_err(|e| format!("{method} {url}: bad JSON: {e}"))
+}
+
+const CALENDAR_API: &str = "https://www.googleapis.com/calendar/v3";
+
+fn google_events_url(cal_id: &str) -> String {
+    format!("{CALENDAR_API}/calendars/{}/events", urlencode(cal_id))
 }
 
-fn sync_google(
+fn fetch_google(
+    client: &reqwest::blocking::Client,
+    token: &str,
     acc: &GoogleAccount,
     window: (NaiveDate, NaiveDate),
-) -> Result<Vec<EventRecord>, String> {
-    let client = reqwest::blocking::Client::builder()
-        .timeout(std::time::Duration::from_secs(60))
-        .build()
-        .map_err(|e| e.to_string())?;
-    let token = google_access_token(&client, acc)?;
-
+    source: &str,
+) -> Result<Remote, String> {
+    let get = |url: &str, q: &[(&str, &str)]| {
+        google_call(client, token, reqwest::Method::GET, url, q, None, None)
+    };
     // Only calendars the user keeps visible in Google's own UI (`selected`);
     // subscribed-but-hidden ones stay hidden here too.
-    let list = google_get(
-        &client, &token,
-        "https://www.googleapis.com/calendar/v3/users/me/calendarList",
-        &[("fields", "items(id,summary,selected,deleted)")],
+    let list = get(
+        &format!("{CALENDAR_API}/users/me/calendarList"),
+        &[("fields", "items(id,summary,selected,deleted,primary,accessRole)")],
     )?;
-    let calendars: Vec<(String, String)> = list["items"]
+    let calendars: Vec<RemoteCalendar> = list["items"]
         .as_array()
-        .map(|items| {
-            items
-                .iter()
-                .filter(|c| c["selected"].as_bool().unwrap_or(false) && !c["deleted"].as_bool().unwrap_or(false))
-                .filter_map(|c| {
-                    Some((c["id"].as_str()?.to_string(), c["summary"].as_str().unwrap_or("?").to_string()))
-                })
-                .collect()
+        .into_iter()
+        .flatten()
+        .filter(|c| c["selected"].as_bool().unwrap_or(false) && !c["deleted"].as_bool().unwrap_or(false))
+        .filter_map(|c| {
+            Some(RemoteCalendar {
+                target: c["id"].as_str()?.to_string(),
+                name: c["summary"].as_str().unwrap_or("?").to_string(),
+                primary: c["primary"].as_bool().unwrap_or(false),
+            })
         })
-        .unwrap_or_default();
-    log::info!("{}: {} visible calendar(s)", acc.email, calendars.len());
+        .collect();
+    let _ = acc;
 
-    let source = format!("google:{}", acc.email);
     let time_min = format!("{}T00:00:00Z", window.0);
     let time_max = format!("{}T00:00:00Z", window.1);
+    let mut events: BTreeMap<String, RemoteEvent> = BTreeMap::new();
     let mut seen = BTreeSet::new();
-    let mut records = Vec::new();
-    for (id, name) in &calendars {
-        let url = format!(
-            "https://www.googleapis.com/calendar/v3/calendars/{}/events",
-            urlencode(id)
-        );
+    for cal in &calendars {
+        let url = google_events_url(&cal.target);
         let mut page_token = String::new();
         loop {
             let mut query = vec![
@@ -345,17 +733,28 @@ fn sync_google(
                 ("timeMin", time_min.as_str()),
                 ("timeMax", time_max.as_str()),
                 ("maxResults", "2500"),
-                ("fields", "nextPageToken,items(summary,status,start,end,iCalUID)"),
+                ("fields", "nextPageToken,items(id,iCalUID,recurringEventId,etag,summary,status,start,end)"),
             ];
             if !page_token.is_empty() {
                 query.push(("pageToken", page_token.as_str()));
             }
-            let page = google_get(&client, &token, &url, &query)
-                .map_err(|e| format!("calendar {name}: {e}"))?;
+            let page = get(&url, &query).map_err(|e| format!("calendar {}: {e}", cal.name))?;
             for item in page["items"].as_array().into_iter().flatten() {
-                if let Some(ev) = google_event(item) {
-                    event_to_records(&ev, window, &source, &mut seen, &mut records);
-                }
+                let Some(ev) = google_event(item) else { continue };
+                let id = item["id"].as_str().unwrap_or("").to_string();
+                let uid = item["iCalUID"].as_str().map(String::from).unwrap_or_else(|| id.clone());
+                let recurring = item["recurringEventId"].is_string();
+                let mut instances = Vec::new();
+                event_to_records(&ev, window, source, recurring, &mut seen, &mut instances);
+                let entry = events.entry(uid).or_insert_with(|| RemoteEvent {
+                    url: reqwest::Url::parse(&format!("{url}/{id}")).expect("valid url"),
+                    etag: item["etag"].as_str().unwrap_or("").to_string(),
+                    recurring,
+                    instances: Vec::new(),
+                    lines: Vec::new(),
+                });
+                entry.recurring |= recurring;
+                entry.instances.extend(instances);
             }
             match page["nextPageToken"].as_str() {
                 Some(t) if !t.is_empty() => page_token = t.to_string(),
@@ -363,7 +762,9 @@ fn sync_google(
             }
         }
     }
-    Ok(records)
+    // Cancelled instances leave an empty entry; drop those.
+    events.retain(|_, e| !e.instances.is_empty());
+    Ok(Remote { calendars, events })
 }
 
 /// Google's `{date}` / `{dateTime}` pair onto the same DtValue the iCal
@@ -383,57 +784,107 @@ fn google_event(item: &serde_json::Value) -> Option<VEvent> {
         dtend: dt(&item["end"]),
         cancelled: item["status"].as_str() == Some("cancelled"),
         has_rrule: false,
+        has_recurrence_id: false,
     })
 }
 
+fn google_body(r: &EventRecord) -> Result<serde_json::Value, String> {
+    let (date, time) = parse_record_time(r)?;
+    let (start, end) = if time.is_some() {
+        let (s, e) = record_span(r)?;
+        (
+            serde_json::json!({ "dateTime": s.to_rfc3339() }),
+            serde_json::json!({ "dateTime": e.to_rfc3339() }),
+        )
+    } else {
+        let next = date.checked_add_days(Days::new(1)).ok_or("date overflow")?;
+        (
+            serde_json::json!({ "date": date.to_string() }),
+            serde_json::json!({ "date": next.to_string() }),
+        )
+    };
+    Ok(serde_json::json!({ "summary": r.title, "start": start, "end": end }))
+}
+
+fn google_create(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    cal_id: &str,
+    r: &EventRecord,
+) -> Result<(String, reqwest::Url, String), String> {
+    let body = google_body(r)?;
+    let url = google_events_url(cal_id);
+    let resp = google_call(client, token, reqwest::Method::POST, &url, &[], Some(&body), None)?;
+    let id = resp["id"].as_str().ok_or("created event has no id")?.to_string();
+    let uid = resp["iCalUID"].as_str().map(String::from).unwrap_or_else(|| id.clone());
+    let event_url = reqwest::Url::parse(&format!("{url}/{id}")).map_err(|e| e.to_string())?;
+    Ok((uid, event_url, resp["etag"].as_str().unwrap_or("").to_string()))
+}
+
+/// Field-level PATCH: summary, start, end — a description, attendees or
+/// reminders set in Google's own apps ride through untouched.
+fn google_update(
+    client: &reqwest::blocking::Client,
+    token: &str,
+    url: &reqwest::Url,
+    etag: &str,
+    r: &EventRecord,
+) -> Result<String, String> {
+    let body = google_body(r)?;
+    let resp = google_call(client, token, reqwest::Method::PATCH, url.as_str(), &[], Some(&body), Some(etag))?;
+    Ok(resp["etag"].as_str().unwrap_or("").to_string())
+}
+
+fn google_delete(client: &reqwest::blocking::Client, token: &str, url: &reqwest::Url) -> Result<(), String> {
+    match google_call(client, token, reqwest::Method::DELETE, url.as_str(), &[], None, None) {
+        Ok(_) => Ok(()),
+        // Already gone counts as done.
+        Err(e) if e.contains("HTTP 404") || e.contains("HTTP 410") => Ok(()),
+        Err(e) => Err(e),
+    }
+}
+
 /// Calendar ids are email-like and go into the path; only the few
 /// characters that could break it need escaping.
 fn urlencode(s: &str) -> String {
     s.replace('%', "%25").replace('/', "%2F").replace('#', "%23").replace('?', "%3F").replace('@', "%40")
 }
 
-// ── CalDAV ────────────────────────────────────────────────────────────────
+// ── CalDAV (iCloud) ───────────────────────────────────────────────────────
 
-fn sync_account(
+fn fetch_icloud(
+    client: &reqwest::blocking::Client,
     acc: &Account,
     window: (NaiveDate, NaiveDate),
-) -> Result<Vec<EventRecord>, String> {
-    let client = reqwest::blocking::Client::builder()
-        .timeout(std::time::Duration::from_secs(60))
-        .build()
-        .map_err(|e| e.to_string())?;
-
+    source: &str,
+) -> Result<Remote, String> {
     let root = reqwest::Url::parse(CALDAV_ROOT).expect("static url");
     let principal = discover_href(
-        &client, acc, &root, "0",
+        client, acc, &root, "0",
         r#"<?xml version="1.0" encoding="utf-8"?>
 <propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>"#,
         "current-user-principal",
     )?;
     let home = discover_href(
-        &client, acc, &principal, "0",
+        client, acc, &principal, "0",
         r#"<?xml version="1.0" encoding="utf-8"?>
 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><prop><C:calendar-home-set/></prop></propfind>"#,
         "calendar-home-set",
     )?;
-    let calendars = list_event_calendars(&client, acc, &home)?;
-    log::info!("{}: {} calendar(s) with events", acc.email, calendars.len());
+    let calendars = list_event_calendars(client, acc, &home)?;
 
     let (start, end) = (
         format!("{}T000000Z", window.0.format("%Y%m%d")),
         format!("{}T000000Z", window.1.format("%Y%m%d")),
     );
-    let source = format!("icloud:{}", acc.email);
+    let mut events = BTreeMap::new();
     let mut seen = BTreeSet::new();
-    let mut records = Vec::new();
-    for (cal_url, cal_name) in &calendars {
-        let events = fetch_events(&client, acc, cal_url, &start, &end)
-            .map_err(|e| format!("calendar {cal_name}: {e}"))?;
-        for ev in events {
-            event_to_records(&ev, window, &source, &mut seen, &mut records);
-        }
+    for cal in &calendars {
+        let cal_url = reqwest::Url::parse(&cal.target).map_err(|e| e.to_string())?;
+        fetch_events(client, acc, &cal_url, &start, &end, window, source, &mut seen, &mut events)
+            .map_err(|e| format!("calendar {}: {e}", cal.name))?;
     }
-    Ok(records)
+    Ok(Remote { calendars, events })
 }
 
 fn dav_request(
@@ -487,12 +938,12 @@ fn discover_href(
 
 /// Depth-1 PROPFIND on the calendar home: the child collections that are
 /// calendars and hold VEVENTs (Reminders lists are VTODO-only and excluded —
-/// they are cce-list's, in a later step).
+/// they are cce-list's).
 fn list_event_calendars(
     client: &reqwest::blocking::Client,
     acc: &Account,
     home: &reqwest::Url,
-) -> Result<Vec<(reqwest::Url, String)>, String> {
+) -> Result<Vec<RemoteCalendar>, String> {
     let body = r#"<?xml version="1.0" encoding="utf-8"?>
 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
   <prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>
@@ -537,7 +988,7 @@ fn list_event_calendars(
         if url.path().trim_end_matches('/') == home.path().trim_end_matches('/') {
             continue; // the home collection lists itself first
         }
-        out.push((url, name));
+        out.push(RemoteCalendar { target: url.to_string(), name, primary: false });
     }
     Ok(out)
 }
@@ -551,7 +1002,7 @@ fn calendar_query(start: &str, end: &str, expand: bool) -> String {
     format!(
         r#"<?xml version="1.0" encoding="utf-8"?>
 <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
-  <D:prop>{data}</D:prop>
+  <D:prop><D:getetag/>{data}</D:prop>
   <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
     <C:time-range start="{start}" end="{end}"/>
   </C:comp-filter></C:comp-filter></C:filter>
@@ -559,13 +1010,18 @@ fn calendar_query(start: &str, end: &str, expand: bool) -> String {
     )
 }
 
+#[allow(clippy::too_many_arguments)]
 fn fetch_events(
     client: &reqwest::blocking::Client,
     acc: &Account,
     cal: &reqwest::Url,
     start: &str,
     end: &str,
-) -> Result<Vec<VEvent>, String> {
+    window: (NaiveDate, NaiveDate),
+    source: &str,
+    seen: &mut BTreeSet<(String, Option<String>, String)>,
+    events: &mut BTreeMap<String, RemoteEvent>,
+) -> Result<(), String> {
     // Server-side expansion first: recurrences come back as concrete
     // instances in UTC, so no RRULE or VTIMEZONE handling is needed here.
     let (xml, expanded) =
@@ -577,22 +1033,120 @@ fn fetch_events(
             }
         };
     let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
-    let mut events = Vec::new();
     let mut skipped_rrule = 0usize;
-    for node in doc.descendants().filter(|n| n.tag_name().name() == "calendar-data") {
-        let Some(ics) = node.text() else { continue };
-        for ev in parse_ics_events(ics) {
-            if !expanded && ev.has_rrule {
-                skipped_rrule += 1;
-                continue;
-            }
-            events.push(ev);
+    for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
+        let href = resp
+            .children()
+            .find(|c| c.tag_name().name() == "href")
+            .and_then(|n| n.text())
+            .unwrap_or_default();
+        let etag = resp
+            .descendants()
+            .find(|n| n.tag_name().name() == "getetag")
+            .and_then(|n| n.text())
+            .unwrap_or_default()
+            .to_string();
+        let Some(ics) = resp
+            .descendants()
+            .find(|n| n.tag_name().name() == "calendar-data")
+            .and_then(|n| n.text())
+        else {
+            continue;
+        };
+        let url = cal.join(href.trim()).map_err(|e| format!("bad href {href:?}: {e}"))?;
+        let vevents = parse_ics_events(ics);
+        // A resource is recurring if it says so, or if expansion produced
+        // more than one dated VEVENT of it.
+        let recurring = vevents.len() > 1
+            || vevents.iter().any(|v| v.has_rrule || v.has_recurrence_id);
+        if !expanded && recurring {
+            skipped_rrule += 1;
+            continue;
         }
+        let Some(uid) = vevents.iter().map(|v| v.uid.clone()).find(|u| !u.is_empty()) else {
+            continue;
+        };
+        let mut instances = Vec::new();
+        for ev in &vevents {
+            event_to_records(ev, window, source, recurring, seen, &mut instances);
+        }
+        events.insert(
+            uid,
+            RemoteEvent { url, etag, recurring, instances, lines: unfold(ics) },
+        );
     }
     if skipped_rrule > 0 {
         log::warn!("{skipped_rrule} recurring event(s) skipped (server refused expansion)");
     }
-    Ok(events)
+    Ok(())
+}
+
+fn put_ics(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    url: &reqwest::Url,
+    body: &str,
+    etag: Option<&str>,
+) -> Result<String, String> {
+    let mut req = client
+        .put(url.clone())
+        .basic_auth(&acc.email, Some(&acc.password))
+        .header("Content-Type", "text/calendar; charset=utf-8")
+        .body(body.to_string());
+    req = match etag {
+        Some(e) if !e.is_empty() => req.header("If-Match", e),
+        Some(_) => req,
+        None => req.header("If-None-Match", "*"),
+    };
+    let resp = req.send().map_err(|e| format!("PUT {url}: {e}"))?;
+    let status = resp.status();
+    if !status.is_success() {
+        return Err(format!("PUT {url}: HTTP {status}"));
+    }
+    let etag = resp
+        .headers()
+        .get("etag")
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or_default()
+        .to_string();
+    if !etag.is_empty() {
+        return Ok(etag);
+    }
+    Ok(fetch_etag(client, acc, url).unwrap_or_default())
+}
+
+fn fetch_etag(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    url: &reqwest::Url,
+) -> Option<String> {
+    let body = r#"<?xml version="1.0" encoding="utf-8"?>
+<propfind xmlns="DAV:"><prop><getetag/></prop></propfind>"#;
+    let xml = dav_request(client, acc, "PROPFIND", url, "0", body).ok()?;
+    let doc = roxmltree::Document::parse(&xml).ok()?;
+    doc.descendants()
+        .find(|n| n.tag_name().name() == "getetag")
+        .and_then(|n| n.text())
+        .map(|s| s.to_string())
+}
+
+fn delete_ics(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    url: &reqwest::Url,
+    etag: &str,
+) -> Result<(), String> {
+    let mut req = client.delete(url.clone()).basic_auth(&acc.email, Some(&acc.password));
+    if !etag.is_empty() {
+        req = req.header("If-Match", etag);
+    }
+    let resp = req.send().map_err(|e| format!("DELETE {url}: {e}"))?;
+    let status = resp.status();
+    if status.is_success() || status == reqwest::StatusCode::NOT_FOUND {
+        Ok(())
+    } else {
+        Err(format!("DELETE {url}: HTTP {status}"))
+    }
 }
 
 // ── iCalendar parsing (the few fields this mirror needs) ──────────────────
@@ -605,6 +1159,7 @@ struct VEvent {
     dtend: Option<DtValue>,
     cancelled: bool,
     has_rrule: bool,
+    has_recurrence_id: bool,
 }
 
 #[derive(Debug, Clone)]
@@ -631,6 +1186,7 @@ fn unfold(ics: &str) -> Vec<String> {
         }
         lines.push(raw.to_string());
     }
+    lines.retain(|l| !l.is_empty());
     lines
 }
 
@@ -647,6 +1203,12 @@ fn split_content_line(line: &str) -> Option<(&str, &str)> {
     None
 }
 
+fn prop_name(line: &str) -> String {
+    split_content_line(line)
+        .map(|(h, _)| h.split(';').next().unwrap_or("").to_ascii_uppercase())
+        .unwrap_or_default()
+}
+
 fn unescape_text(v: &str) -> String {
     let mut out = String::with_capacity(v.len());
     let mut chars = v.chars();
@@ -664,6 +1226,20 @@ fn unescape_text(v: &str) -> String {
     out
 }
 
+fn escape_text(v: &str) -> String {
+    let mut out = String::with_capacity(v.len());
+    for c in v.chars() {
+        match c {
+            '\\' => out.push_str("\\\\"),
+            ',' => out.push_str("\\,"),
+            ';' => out.push_str("\\;"),
+            '\n' => out.push_str("\\n"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
 fn parse_dt(name_and_params: &str, value: &str) -> Option<DtValue> {
     let mut tzid = None;
     let mut is_date = false;
@@ -710,6 +1286,7 @@ fn parse_ics_events(ics: &str) -> Vec<VEvent> {
                     "DTSTART" => ev.dtstart = parse_dt(head, value),
                     "DTEND" => ev.dtend = parse_dt(head, value),
                     "RRULE" | "RDATE" => ev.has_rrule = true,
+                    "RECURRENCE-ID" => ev.has_recurrence_id = true,
                     "STATUS" => ev.cancelled = value.trim().eq_ignore_ascii_case("CANCELLED"),
                     _ => {}
                 }
@@ -719,6 +1296,85 @@ fn parse_ics_events(ics: &str) -> Vec<VEvent> {
     events
 }
 
+/// The DTSTART/DTEND pair for a record: UTC for timed events, DATE for
+/// all-day ones (DTEND exclusive, one day).
+fn ics_span(r: &EventRecord) -> Result<(String, String), String> {
+    let (date, time) = parse_record_time(r)?;
+    if time.is_some() {
+        let (s, e) = record_span(r)?;
+        let fmt = |t: DateTime<Local>| t.with_timezone(&Utc).format("%Y%m%dT%H%M%SZ").to_string();
+        Ok((format!("DTSTART:{}", fmt(s)), format!("DTEND:{}", fmt(e))))
+    } else {
+        let next = date.checked_add_days(Days::new(1)).ok_or("date overflow")?;
+        Ok((
+            format!("DTSTART;VALUE=DATE:{}", date.format("%Y%m%d")),
+            format!("DTEND;VALUE=DATE:{}", next.format("%Y%m%d")),
+        ))
+    }
+}
+
+fn new_vevent(uid: &str, r: &EventRecord) -> Result<String, String> {
+    let now = Utc::now().format("%Y%m%dT%H%M%SZ");
+    let (dtstart, dtend) = ics_span(r)?;
+    Ok(format!(
+        "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//cce//cce-calendar-sync//EN\r\n\
+         BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{now}\r\nCREATED:{now}\r\n{dtstart}\r\n{dtend}\r\n\
+         SUMMARY:{}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
+        escape_text(&r.title)
+    ))
+}
+
+/// Rewrite only SUMMARY, DTSTART and DTEND (and drop a DURATION, which
+/// DTEND supersedes) inside the first VEVENT, leaving every other property
+/// — VALARM, X-APPLE-*, DESCRIPTION — exactly as the server sent it.
+fn patch_vevent(lines: &[String], r: &EventRecord) -> Result<String, String> {
+    let (dtstart, dtend) = ics_span(r)?;
+    let mut out: Vec<String> = Vec::with_capacity(lines.len() + 3);
+    let mut in_event = false;
+    let mut patched = false;
+    for line in lines {
+        let name = prop_name(line);
+        let value = split_content_line(line).map(|(_, v)| v).unwrap_or_default();
+        if name == "BEGIN" && value.eq_ignore_ascii_case("VEVENT") && !patched {
+            in_event = true;
+            out.push(line.clone());
+            continue;
+        }
+        if in_event && name == "END" && value.eq_ignore_ascii_case("VEVENT") {
+            out.push(format!("SUMMARY:{}", escape_text(&r.title)));
+            out.push(dtstart.clone());
+            out.push(dtend.clone());
+            in_event = false;
+            patched = true;
+            out.push(line.clone());
+            continue;
+        }
+        if in_event && matches!(name.as_str(), "SUMMARY" | "DTSTART" | "DTEND" | "DURATION") {
+            continue;
+        }
+        out.push(line.clone());
+    }
+    if !patched {
+        return Err("no VEVENT to patch".into());
+    }
+    let mut s = out.join("\r\n");
+    s.push_str("\r\n");
+    Ok(s)
+}
+
+/// Random-enough UID from the kernel, no uuid dependency.
+fn new_uid() -> String {
+    let mut bytes = [0u8; 16];
+    if std::fs::File::open("/dev/urandom")
+        .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
+        .is_err()
+    {
+        return format!("CCE-{}", Utc::now().format("%Y%m%dT%H%M%S%fZ"));
+    }
+    let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
+    format!("CCE-{}-{}-{}", &hex[..8], &hex[8..16], &hex[16..])
+}
+
 // ── VEvent → records ──────────────────────────────────────────────────────
 
 fn to_local(dt: &DtValue) -> (NaiveDate, Option<(u32, u32)>) {
@@ -748,6 +1404,7 @@ fn event_to_records(
     ev: &VEvent,
     window: (NaiveDate, NaiveDate),
     source: &str,
+    recurring: bool,
     seen: &mut BTreeSet<(String, Option<String>, String)>,
     out: &mut Vec<EventRecord>,
 ) {
@@ -773,6 +1430,7 @@ fn event_to_records(
                 title: title.clone(),
                 uid: uid.clone(),
                 source: Some(source.to_string()),
+                recurring,
             });
         }
     };
@@ -781,7 +1439,9 @@ fn event_to_records(
         (date, Some(time)) => push(date, Some(time)),
         (start, None) => {
             // All-day: DTEND is exclusive per RFC 5545; a missing one means
-            // a single day. One untimed record per covered day.
+            // a single day. One untimed record per covered day. A multi-day
+            // all-day event is several records of one uid, which the file
+            // cannot edit as one thing — so it is mirrored read-only too.
             let end = match ev.dtend.as_ref().map(to_local) {
                 Some((d, _)) if d > start => d,
                 _ => start.checked_add_days(Days::new(1)).unwrap_or(start),
@@ -798,36 +1458,18 @@ fn event_to_records(
     }
 }
 
-// ── Merge ─────────────────────────────────────────────────────────────────
-
-/// Replace each synced source's records wholesale; everything else — local
-/// events and sources not synced this run — passes through untouched.
-fn merge(existing: Vec<EventRecord>, synced: &[(String, Vec<EventRecord>)]) -> Vec<EventRecord> {
-    let replaced: Vec<&str> = synced.iter().map(|(s, _)| s.as_str()).collect();
-    let mut merged: Vec<EventRecord> = existing
-        .into_iter()
-        .filter(|r| !r.source.as_deref().is_some_and(|s| replaced.contains(&s)))
-        .collect();
-    for (_, records) in synced {
-        merged.extend(records.iter().cloned());
-    }
-    merged.sort_by(|a, b| {
-        (&a.date, a.time.is_none(), &a.time, &a.title).cmp(&(&b.date, b.time.is_none(), &b.time, &b.title))
-    });
-    merged
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    fn record(date: &str, source: Option<&str>, title: &str) -> EventRecord {
+    fn record(date: &str, time: Option<&str>, title: &str) -> EventRecord {
         EventRecord {
             date: date.into(),
-            time: None,
+            time: time.map(String::from),
             title: title.into(),
             uid: None,
-            source: source.map(String::from),
+            source: None,
+            recurring: false,
         }
     }
 
@@ -845,7 +1487,7 @@ mod tests {
         assert_eq!(events[0].uid, "abc");
         assert_eq!(events[0].summary, "Dentist, checkup");
         assert!(matches!(events[0].dtstart, Some(DtValue::Utc(_))));
-        assert!(!events[0].has_rrule);
+        assert!(!events[0].has_rrule && !events[0].has_recurrence_id);
     }
 
     #[test]
@@ -862,7 +1504,7 @@ mod tests {
             NaiveDate::from_ymd_opt(2026, 12, 1).unwrap(),
         );
         let (mut seen, mut out) = (BTreeSet::new(), Vec::new());
-        event_to_records(&ev, window, "icloud:x", &mut seen, &mut out);
+        event_to_records(&ev, window, "icloud:x", false, &mut seen, &mut out);
         assert_eq!(
             out.iter().map(|r| r.date.as_str()).collect::<Vec<_>>(),
             vec!["2026-09-10", "2026-09-11", "2026-09-12"]
@@ -879,20 +1521,56 @@ mod tests {
             NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
             NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
         );
-        event_to_records(ev, window, "s", &mut seen, &mut out);
+        event_to_records(ev, window, "s", false, &mut seen, &mut out);
         assert!(out.is_empty());
     }
 
     #[test]
-    fn merge_replaces_own_source_and_keeps_the_rest() {
-        let existing = vec![
-            record("2026-09-01", None, "hand-entered"),
-            record("2026-09-02", Some("icloud:[email protected]"), "stale"),
-            record("2026-09-03", Some("icloud:[email protected]"), "foreign"),
-        ];
-        let fresh = vec![record("2026-09-04", Some("icloud:[email protected]"), "fresh")];
-        let merged = merge(existing, &[("icloud:[email protected]".to_string(), fresh)]);
-        let titles: Vec<_> = merged.iter().map(|r| r.title.as_str()).collect();
-        assert_eq!(titles, vec!["hand-entered", "foreign", "fresh"]);
+    fn new_vevent_all_day_and_timed() {
+        let all_day = new_vevent("U1", &record("2026-09-10", None, "Trip, day")).unwrap();
+        assert!(all_day.contains("DTSTART;VALUE=DATE:20260910"));
+        assert!(all_day.contains("DTEND;VALUE=DATE:20260911"));
+        assert!(all_day.contains("SUMMARY:Trip\\, day"));
+        let timed = new_vevent("U2", &record("2026-09-10", Some("09:30"), "Call")).unwrap();
+        // UTC form, an hour long; the exact hour depends on the local zone.
+        assert!(timed.contains("DTSTART:20260910T") || timed.contains("DTSTART:20260911T"));
+        assert!(timed.contains("Z\r\nDTEND:"));
+    }
+
+    #[test]
+    fn patch_keeps_foreign_properties_and_replaces_the_span() {
+        let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u\r\nDTSTART;TZID=America/New_York:20261001T090000\r\nDURATION:PT30M\r\nSUMMARY:old\r\nBEGIN:VALARM\r\nTRIGGER:-PT10M\r\nEND:VALARM\r\nX-APPLE-TRAVEL:1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
+        let patched = patch_vevent(&unfold(ics), &record("2026-10-02", None, "new")).unwrap();
+        assert!(patched.contains("BEGIN:VALARM"));
+        assert!(patched.contains("X-APPLE-TRAVEL:1"));
+        assert!(patched.contains("SUMMARY:new"));
+        assert!(patched.contains("DTSTART;VALUE=DATE:20261002"));
+        assert!(!patched.contains("SUMMARY:old"));
+        assert!(!patched.contains("DURATION"));
+        assert!(!patched.contains("TZID"));
+    }
+
+    #[test]
+    fn google_body_shapes() {
+        let all_day = google_body(&record("2026-09-10", None, "x")).unwrap();
+        assert_eq!(all_day["start"]["date"], "2026-09-10");
+        assert_eq!(all_day["end"]["date"], "2026-09-11");
+        let timed = google_body(&record("2026-09-10", Some("09:30"), "x")).unwrap();
+        assert!(timed["start"]["dateTime"].as_str().unwrap().starts_with("2026-09-10T09:30:00"));
+    }
+
+    #[test]
+    fn synced_event_matches_on_the_editable_triple() {
+        let s = SyncedEvent {
+            source: "icloud:a".into(),
+            url: "u".into(),
+            etag: "e".into(),
+            date: "2026-09-10".into(),
+            time: Some("09:30".into()),
+            title: "x".into(),
+        };
+        assert!(s.matches(&record("2026-09-10", Some("09:30"), "x")));
+        assert!(!s.matches(&record("2026-09-10", Some("10:30"), "x")));
+        assert!(!s.matches(&record("2026-09-10", Some("09:30"), "y")));
     }
 }
diff --git a/src/lib.rs b/src/lib.rs
index b45b07b..d996f3c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,15 +1,19 @@
 //! Shared between the `cce-calendar` app and the `cce-calendar-sync` helper:
-//! the on-disk event record and its file. Both binaries read and rewrite the
-//! same `events.json`, so the record shape lives in one place.
+//! the on-disk event record and its file, the sync-state sidecar, and the
+//! push-target config. Both binaries read and rewrite the same
+//! `events.json`, so the record shape lives in one place.
 
+use std::collections::BTreeMap;
 use std::path::PathBuf;
 
 /// The on-disk shape: a flat list keeps the file trivially mergeable and
 /// greppable. `uid`/`source` are set only on records mirrored from a remote
-/// calendar by `cce-calendar-sync`; hand-entered events carry neither. The
-/// app preserves them through its own saves, and the sync helper replaces
-/// exactly the records whose `source` matches the account it is syncing.
-#[derive(Clone, serde::Serialize, serde::Deserialize)]
+/// calendar by `cce-calendar-sync`; hand-entered events carry neither until
+/// the sync has pushed them to the default calendar and written the identity
+/// back. `recurring` marks an expanded instance of a repeating event: those
+/// are mirrored read-only (deleting one here would mean nothing the server
+/// could express), and the app refuses to delete them.
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
 pub struct EventRecord {
     pub date: String,
     #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -19,16 +23,26 @@ pub struct EventRecord {
     pub uid: Option<String>,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub source: Option<String>,
+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+    pub recurring: bool,
 }
 
-pub fn data_path() -> PathBuf {
+fn data_dir() -> PathBuf {
     std::env::var_os("XDG_DATA_HOME")
         .map(PathBuf::from)
         .filter(|p| p.is_absolute())
         .unwrap_or_else(|| {
             PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
         })
-        .join("cce/calendar/events.json")
+        .join("cce/calendar")
+}
+
+pub fn data_path() -> PathBuf {
+    data_dir().join("events.json")
+}
+
+pub fn sync_state_path() -> PathBuf {
+    data_dir().join("sync-state.json")
 }
 
 pub fn load_records() -> std::io::Result<Vec<EventRecord>> {
@@ -43,11 +57,115 @@ pub fn load_records() -> std::io::Result<Vec<EventRecord>> {
 /// Write-temp-then-rename in the same directory, so a crash mid-write never
 /// leaves a truncated file — two writers (app and sync timer) share this file.
 pub fn save_records(records: &[EventRecord]) -> std::io::Result<()> {
-    let path = data_path();
+    atomic_write(&data_path(), &serde_json::to_string_pretty(records).unwrap_or_default())
+}
+
+pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
     if let Some(dir) = path.parent() {
         std::fs::create_dir_all(dir)?;
     }
     let tmp = path.with_extension("json.tmp");
-    std::fs::write(&tmp, serde_json::to_string_pretty(records).unwrap_or_default())?;
-    std::fs::rename(&tmp, &path)
+    std::fs::write(&tmp, content)?;
+    std::fs::rename(&tmp, path)
+}
+
+/// Stable order for the file: by date, timed before untimed, then title.
+pub fn sort_records(records: &mut [EventRecord]) {
+    records.sort_by(|a, b| {
+        (&a.date, a.time.is_none(), &a.time, &a.title).cmp(&(&b.date, b.time.is_none(), &b.time, &b.title))
+    });
+}
+
+// ── Sync state (cce-calendar-sync's merge base; the app never touches it) ─
+
+/// What the server held for one NON-recurring event at the end of the last
+/// sync. Comparing the file and the server against this tells "the user
+/// changed it here" apart from "it changed on the phone"; a uid in the state
+/// but missing from the file is a local deletion to push.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct SyncedEvent {
+    /// "icloud:<email>" / "google:<email>".
+    pub source: String,
+    /// Absolute resource URL (PUT/PATCH/DELETE target).
+    pub url: String,
+    pub etag: String,
+    pub date: String,
+    #[serde(default)]
+    pub time: Option<String>,
+    pub title: String,
+}
+
+impl SyncedEvent {
+    pub fn matches(&self, r: &EventRecord) -> bool {
+        self.date == r.date && self.time == r.time && self.title == r.title
+    }
+}
+
+#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+pub struct SyncState {
+    #[serde(default)]
+    pub events: BTreeMap<String, SyncedEvent>,
+}
+
+pub fn load_sync_state() -> std::io::Result<SyncState> {
+    let text = match std::fs::read_to_string(sync_state_path()) {
+        Ok(t) => t,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SyncState::default()),
+        Err(e) => return Err(e),
+    };
+    serde_json::from_str(&text).map_err(std::io::Error::other)
+}
+
+pub fn save_sync_state(state: &SyncState) -> std::io::Result<()> {
+    atomic_write(&sync_state_path(), &serde_json::to_string_pretty(state).unwrap_or_default())
+}
+
+// ── Config ────────────────────────────────────────────────────────────────
+
+/// Where events typed into the app are created. From
+/// `~/.config/cce/cce-calendar/config.kdl`:
+///
+/// ```kdl
+/// push-to "icloud"                      // first iCloud event calendar
+/// push-to "icloud" calendar="Home"      // a named one
+/// push-to "google"                      // the Google primary calendar
+/// push-to "none"                        // keep typed events local
+/// ```
+///
+/// Absent, the sync picks iCloud if such an account exists, else Google.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PushTarget {
+    /// "icloud", "google", or "none".
+    pub kind: String,
+    pub calendar: Option<String>,
+}
+
+pub fn push_target_config() -> Option<PushTarget> {
+    let path = cce_ui::config::get_app_config_path("cce-calendar");
+    let text = std::fs::read_to_string(path).ok()?;
+    let doc = text.parse::<kdl::KdlDocument>().ok()?;
+    let node = doc.get("push-to")?;
+    let kind = node.entries().iter().find(|e| e.name().is_none())?.value().as_string()?;
+    let calendar = node
+        .entries()
+        .iter()
+        .find(|e| e.name().map(|n| n.value()) == Some("calendar"))
+        .and_then(|e| e.value().as_string())
+        .map(String::from);
+    Some(PushTarget { kind: kind.to_ascii_lowercase(), calendar })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn recurring_flag_is_optional_on_disk() {
+        let plain: EventRecord = serde_json::from_str(r#"{"date":"2026-09-10","title":"x"}"#).unwrap();
+        assert!(!plain.recurring);
+        let json = serde_json::to_string(&plain).unwrap();
+        assert!(!json.contains("recurring"));
+        let rec = EventRecord { recurring: true, ..plain };
+        assert!(serde_json::to_string(&rec).unwrap().contains("\"recurring\":true"));
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index ca29c21..979a28e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,8 +4,11 @@
 //! `$XDG_DATA_HOME/cce/calendar/events.json` (one flat list of
 //! date/time/title records) and are saved on every mutation. Records with a
 //! `source` are mirrored from a remote calendar by `cce-calendar-sync`
-//! (blue dot); they can be deleted here, but the next sync tick restores
-//! them — the server owns them.
+//! (blue dot). Events typed here are pushed to the default calendar on the
+//! next sync tick and come back carrying their identity; deleting a synced
+//! event here deletes it on the server — except instances of a recurring
+//! event, which are read-only mirrors (the app refuses). The file is
+//! re-read once a second when the sync rewrites it.
 //!
 //! Keys: arrows move the selected day · PageUp/PageDown month · [/] year ·
 //! t/Home today · n/Enter new event (a leading `HH:MM` token sets the
@@ -13,7 +16,9 @@
 //! months over the grid and scrolls the event list over the pane.
 //!
 //! Config (`~/.config/cce/cce-calendar/config.kdl`): `week-start "sunday"`
-//! (default monday).
+//! (default monday); `push-to "icloud"` / `"google"` / `"none"`, optionally
+//! `calendar="Name"`, picks where typed events are created (the sync's
+//! default is iCloud when such an account exists).
 
 use std::collections::BTreeMap;
 
@@ -64,6 +69,7 @@ struct Event {
     title: String,
     uid: Option<String>,
     source: Option<String>,
+    recurring: bool,
 }
 
 fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
@@ -85,6 +91,7 @@ fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
             title: rec.title,
             uid: rec.uid,
             source: rec.source,
+            recurring: rec.recurring,
         });
     }
     for events in map.values_mut() {
@@ -118,7 +125,7 @@ fn parse_event(raw: &str) -> Option<Event> {
         },
     };
     let title = if title.is_empty() { "(untitled)".to_string() } else { title };
-    Some(Event { time, title, uid: None, source: None })
+    Some(Event { time, title, uid: None, source: None, recurring: false })
 }
 
 fn week_start_config() -> Weekday {
@@ -202,6 +209,14 @@ struct CalendarApp {
     /// pixel event. Cleared by a wheel notch and by the finger lift.
     month_wheel_px: f32,
     status: Option<String>,
+    /// events.json as last read: mtime, polled once a second so the sync's
+    /// rewrites (pushed identities, phone-side changes) show up live.
+    file_mtime: Option<std::time::SystemTime>,
+    watch_timer: f32,
+}
+
+fn file_mtime() -> Option<std::time::SystemTime> {
+    std::fs::metadata(cce_calendar::data_path()).and_then(|m| m.modified()).ok()
 }
 
 impl CalendarApp {
@@ -252,6 +267,7 @@ impl CalendarApp {
         self.selected = date;
         self.sel_event = None;
         self.sidebar_scroll = 0.0;
+        self.status = None;
         if (date.year(), date.month()) != self.view {
             self.view = (date.year(), date.month());
         }
@@ -309,10 +325,24 @@ impl CalendarApp {
                     title: e.title.clone(),
                     uid: e.uid.clone(),
                     source: e.source.clone(),
+                    recurring: e.recurring,
                 })
             })
             .collect();
         self.status = save_records(&records).err().map(|e| format!("save failed: {e}"));
+        // Our own write must not read as an outside change next tick.
+        self.file_mtime = file_mtime();
+    }
+
+    /// Re-read events.json after something else wrote it, keeping the
+    /// selection where it still makes sense.
+    fn reload(&mut self) {
+        self.events = load_events();
+        self.file_mtime = file_mtime();
+        let n = self.events.get(&self.selected).map_or(0, Vec::len);
+        if self.sel_event.is_some_and(|i| i >= n) {
+            self.sel_event = None;
+        }
     }
 
     fn commit_input(&mut self) {
@@ -332,6 +362,13 @@ impl CalendarApp {
             return;
         };
         if let Some(day) = self.events.get_mut(&self.selected) {
+            if day.get(idx).is_some_and(|e| e.recurring) {
+                // One instance of a repeating event: the server has no
+                // "just this one" the mirror could express, so it stays.
+                self.sel_event = Some(idx);
+                self.status = Some("repeating event — change it on the phone".to_string());
+                return;
+            }
             if idx < day.len() {
                 day.remove(idx);
                 if day.is_empty() {
@@ -608,6 +645,8 @@ impl Application for CalendarApp {
             sidebar_motion: ScrollMotion::new(),
             month_wheel_px: 0.0,
             status: None,
+            file_mtime: file_mtime(),
+            watch_timer: 0.0,
         }
     }
 
@@ -637,6 +676,16 @@ impl Application for CalendarApp {
         if self.tick_sidebar_scroll(dt) {
             *needs_rebuild = true;
         }
+        // The sync timer rewrites events.json; pick that up without a
+        // relaunch — but not mid-typing, which a reload would clobber.
+        self.watch_timer += dt;
+        if self.watch_timer >= 1.0 {
+            self.watch_timer = 0.0;
+            if self.input.is_none() && file_mtime() != self.file_mtime {
+                self.reload();
+                *needs_rebuild = true;
+            }
+        }
     }
 
     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {