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

commit664f8dcfc5343ae93741d2d47f7ab88d1f3dd039
parentf38a59daad
authorLucas Galante <[email protected]>
date2026-09-01 22:40
Mirror iCloud calendars via CalDAV (cce-calendar-sync)

A second [[bin]], cce-calendar-sync, does a read-only CalDAV pass against
caldav.icloud.com for every iCloud account in accounts.json, using the same
keyring credentials cce-mail resolves (an Apple app-specific password is
valid for CalDAV as well as IMAP). Recurrences are expanded server-side
(<C:expand>), which also normalizes times to UTC, so no RRULE or VTIMEZONE
handling is needed; if a calendar refuses expansion the query retries plain
and recurring events are skipped with a log line rather than shown on the
wrong day.

Records gain optional uid/source fields, shared through the new lib target
so both binaries read and rewrite one events.json without stripping each
other's fields. The sync replaces exactly its own source's records
("icloud:<email>"); hand-entered events pass through untouched, and saves
are temp+rename now that two writers share the file. Synced events draw a
blue dot in the grid. Deleting one in the app only lasts until the next
sync tick - the server owns them.

Driven by cce-calendar-sync.timer every 30 minutes (the timer+service pair
ccebuild installs); failures exit nonzero and the timer just retries.
Google's calendars need an OAuth scope, not an app password - that is a
later step in cce-system-interface.

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

 Cargo.toml                |  16 ++
 cce-calendar-sync.service |  10 +
 cce-calendar-sync.timer   |  10 +
 src/bin/sync.rs           | 668 ++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs                |  53 ++++
 src/main.rs               |  62 ++---
 6 files changed, 781 insertions(+), 38 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index be37636..b3f961d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,3 +13,19 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 log = "0.4"
 env_logger = "0.11"
+# cce-calendar-sync only: CalDAV against iCloud, credentials from the same
+# keyring service cce-mail uses.
+reqwest = { version = "0.12", features = ["blocking"] }
+roxmltree = "0.20"
+chrono-tz = "0.10"
+keyring = { version = "3", features = ["sync-secret-service"] }
+
+# The month-view app; src/bin/sync.rs adds the cce-calendar-sync helper,
+# which ccebuild discovers via cargo metadata like any other [[bin]].
+[[bin]]
+name = "cce-calendar"
+path = "src/main.rs"
+
+[[bin]]
+name = "cce-calendar-sync"
+path = "src/bin/sync.rs"
diff --git a/cce-calendar-sync.service b/cce-calendar-sync.service
new file mode 100644
index 0000000..2b1fc5a
--- /dev/null
+++ b/cce-calendar-sync.service
@@ -0,0 +1,10 @@
+# One read-only CalDAV pass mirroring iCloud calendars into cce-calendar's
+# events.json (see src/bin/sync.rs). Driven by cce-calendar-sync.timer;
+# harmless to start by hand. Fails (locked keyring, no network, iCloud down)
+# exit nonzero and the timer simply tries again next tick.
+[Unit]
+Description=Mirror iCloud calendars into cce-calendar
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/cce-calendar-sync
diff --git a/cce-calendar-sync.timer b/cce-calendar-sync.timer
new file mode 100644
index 0000000..c048299
--- /dev/null
+++ b/cce-calendar-sync.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Mirror iCloud calendars into cce-calendar every 30 minutes
+
+[Timer]
+OnBootSec=3min
+OnUnitActiveSec=30min
+RandomizedDelaySec=2min
+
+[Install]
+WantedBy=timers.target
diff --git a/src/bin/sync.rs b/src/bin/sync.rs
new file mode 100644
index 0000000..e5420c9
--- /dev/null
+++ b/src/bin/sync.rs
@@ -0,0 +1,668 @@
+//! `cce-calendar-sync` — read-only CalDAV mirror of iCloud calendars into
+//! cce-calendar's `events.json`.
+//!
+//! Accounts come from the same `accounts.json` cce-mail reads (owned by
+//! cce-system-interface), passwords from the same `cce-mail` keyring service —
+//! an Apple app-specific password is valid for CalDAV as well as IMAP, so the
+//! credential that fetches mail fetches the calendar too. Only iCloud
+//! accounts are synced; Google's CalDAV endpoint refuses app passwords and
+//! waits on an OAuth scope change (step 3 of the sync plan).
+//!
+//! Each run replaces exactly the records whose `source` matches the account
+//! being synced ("icloud:<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.
+//!
+//! Recurring events are expanded server-side (`<C:expand>`), which also
+//! normalizes times to UTC. If a calendar's 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.
+//!
+//! 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.
+
+use std::collections::BTreeSet;
+use std::str::FromStr;
+
+use chrono::{DateTime, Days, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
+use cce_calendar::{load_records, save_records, EventRecord};
+
+const CALDAV_ROOT: &str = "https://caldav.icloud.com/";
+/// Sync window around today. Wide enough forward that "next spring" plans
+/// show up; bounded so the file stays a glanceable flat list.
+const PAST_DAYS: u64 = 60;
+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;
+
+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 accounts = match icloud_accounts() {
+        Ok(a) => a,
+        Err(e) => {
+            log::error!("cannot read accounts: {e}");
+            std::process::exit(1);
+        }
+    };
+    if accounts.is_empty() {
+        log::info!("no iCloud accounts in accounts.json; nothing to sync");
+        return;
+    }
+
+    let today = Local::now().date_naive();
+    let window = (
+        today.checked_sub_days(Days::new(PAST_DAYS)).unwrap_or(today),
+        today.checked_add_days(Days::new(FUTURE_DAYS)).unwrap_or(today),
+    );
+
+    let mut failed = false;
+    let mut synced: Vec<(String, Vec<EventRecord>)> = Vec::new();
+    for acc in &accounts {
+        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));
+            }
+            Err(e) => {
+                log::error!("{}: sync failed, keeping existing records: {e}", acc.email);
+                failed = true;
+            }
+        }
+    }
+
+    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);
+            }
+        };
+        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);
+                }
+            }
+            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;
+        }
+    }
+    if failed {
+        std::process::exit(1);
+    }
+}
+
+// ── Accounts ──────────────────────────────────────────────────────────────
+
+struct Account {
+    email: String,
+    password: String,
+}
+
+/// The subset of cce-mail's AccountInfo this helper needs. Unknown fields
+/// are ignored, so the two readers cannot drift apart.
+#[derive(serde::Deserialize)]
+struct AccountOnDisk {
+    email: String,
+    #[serde(default)]
+    imap: String,
+    #[serde(default)]
+    password: String,
+}
+
+fn icloud_accounts() -> Result<Vec<Account>, String> {
+    let path = cce_ui::config::cce_config_dir().join("accounts.json");
+    let text = std::fs::read_to_string(&path)
+        .map_err(|e| format!("{}: {e}", path.display()))?;
+    let on_disk: Vec<AccountOnDisk> =
+        serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
+
+    let mut out = Vec::new();
+    for acc in on_disk {
+        if !is_icloud(&acc) {
+            continue;
+        }
+        // Same resolution order as cce-mail: a plaintext on-disk password is
+        // still valid pre-migration; an empty one lives in the keyring under
+        // the "cce-mail" service. This helper only reads — migration into
+        // the keyring stays cce-mail's job.
+        let password = if !acc.password.is_empty() {
+            acc.password.clone()
+        } else {
+            match keyring::Entry::new("cce-mail", &acc.email).and_then(|e| e.get_password()) {
+                Ok(p) => p,
+                Err(e) => {
+                    log::warn!("{}: no password available ({e}); skipping", acc.email);
+                    continue;
+                }
+            }
+        };
+        out.push(Account { email: acc.email, password });
+    }
+    Ok(out)
+}
+
+fn is_icloud(acc: &AccountOnDisk) -> bool {
+    let host = acc.imap.split(':').next().unwrap_or("");
+    host.ends_with(".mail.me.com")
+        || ["@icloud.com", "@me.com", "@mac.com"].iter().any(|d| acc.email.ends_with(d))
+}
+
+// ── CalDAV ────────────────────────────────────────────────────────────────
+
+fn sync_account(
+    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())?;
+
+    let root = reqwest::Url::parse(CALDAV_ROOT).expect("static url");
+    let principal = discover_href(
+        &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",
+        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 (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 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);
+        }
+    }
+    Ok(records)
+}
+
+fn dav_request(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    method: &str,
+    url: &reqwest::Url,
+    depth: &str,
+    body: &str,
+) -> Result<String, String> {
+    let resp = client
+        .request(
+            reqwest::Method::from_bytes(method.as_bytes()).expect("static method"),
+            url.clone(),
+        )
+        .basic_auth(&acc.email, Some(&acc.password))
+        .header("Depth", depth)
+        .header("Content-Type", "application/xml; charset=utf-8")
+        .body(body.to_string())
+        .send()
+        .map_err(|e| format!("{method} {url}: {e}"))?;
+    let status = resp.status();
+    let text = resp.text().map_err(|e| e.to_string())?;
+    if !status.is_success() {
+        // 401 here usually means the app-specific password predates 2FA or
+        // was revoked — generating a fresh one on appleid.apple.com fixes it.
+        return Err(format!("{method} {url}: HTTP {status}"));
+    }
+    Ok(text)
+}
+
+/// PROPFIND for a single href-valued property (principal, calendar home).
+fn discover_href(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    url: &reqwest::Url,
+    depth: &str,
+    body: &str,
+    prop: &str,
+) -> Result<reqwest::Url, String> {
+    let xml = dav_request(client, acc, "PROPFIND", url, depth, body)?;
+    let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
+    let href = doc
+        .descendants()
+        .find(|n| n.tag_name().name() == prop)
+        .and_then(|n| n.descendants().find(|c| c.tag_name().name() == "href"))
+        .and_then(|n| n.text())
+        .ok_or_else(|| format!("no {prop} in PROPFIND response"))?;
+    url.join(href.trim()).map_err(|e| format!("bad {prop} href {href:?}: {e}"))
+}
+
+/// 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).
+fn list_event_calendars(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    home: &reqwest::Url,
+) -> Result<Vec<(reqwest::Url, String)>, 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>
+</propfind>"#;
+    let xml = dav_request(client, acc, "PROPFIND", home, "1", body)?;
+    let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
+
+    let mut out = Vec::new();
+    for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
+        let Some(href) = resp
+            .children()
+            .find(|c| c.tag_name().name() == "href")
+            .and_then(|n| n.text())
+        else {
+            continue;
+        };
+        let is_calendar = resp.descendants().any(|n| {
+            n.tag_name().name() == "calendar" && n.tag_name().namespace() == Some(CALDAV_NS)
+        });
+        if !is_calendar {
+            continue;
+        }
+        // If the server states the component set, require VEVENT; if the
+        // property is absent (404 propstat), assume events.
+        let comps: Vec<_> = resp
+            .descendants()
+            .filter(|n| n.tag_name().name() == "comp")
+            .filter_map(|n| n.attribute("name"))
+            .collect();
+        if !comps.is_empty() && !comps.contains(&"VEVENT") {
+            continue;
+        }
+        let name = resp
+            .descendants()
+            .find(|n| n.tag_name().name() == "displayname")
+            .and_then(|n| n.text())
+            .unwrap_or(href)
+            .to_string();
+        let url = home
+            .join(href.trim())
+            .map_err(|e| format!("bad calendar href {href:?}: {e}"))?;
+        if url.path().trim_end_matches('/') == home.path().trim_end_matches('/') {
+            continue; // the home collection lists itself first
+        }
+        out.push((url, name));
+    }
+    Ok(out)
+}
+
+fn calendar_query(start: &str, end: &str, expand: bool) -> String {
+    let data = if expand {
+        format!(r#"<C:calendar-data><C:expand start="{start}" end="{end}"/></C:calendar-data>"#)
+    } else {
+        "<C:calendar-data/>".to_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>
+  <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>
+</C:calendar-query>"#
+    )
+}
+
+fn fetch_events(
+    client: &reqwest::blocking::Client,
+    acc: &Account,
+    cal: &reqwest::Url,
+    start: &str,
+    end: &str,
+) -> Result<Vec<VEvent>, 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) =
+        match dav_request(client, acc, "REPORT", cal, "1", &calendar_query(start, end, true)) {
+            Ok(xml) => (xml, true),
+            Err(e) => {
+                log::warn!("expand REPORT failed ({e}); retrying without expansion");
+                (dav_request(client, acc, "REPORT", cal, "1", &calendar_query(start, end, false))?, false)
+            }
+        };
+    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);
+        }
+    }
+    if skipped_rrule > 0 {
+        log::warn!("{skipped_rrule} recurring event(s) skipped (server refused expansion)");
+    }
+    Ok(events)
+}
+
+// ── iCalendar parsing (the few fields this mirror needs) ──────────────────
+
+#[derive(Debug, Default)]
+struct VEvent {
+    uid: String,
+    summary: String,
+    dtstart: Option<DtValue>,
+    dtend: Option<DtValue>,
+    cancelled: bool,
+    has_rrule: bool,
+}
+
+#[derive(Debug, Clone)]
+enum DtValue {
+    /// All-day (VALUE=DATE).
+    Date(NaiveDate),
+    /// Zulu-suffixed date-time (what `<C:expand>` yields).
+    Utc(DateTime<Utc>),
+    /// Floating or TZID-qualified local time.
+    Zoned(NaiveDateTime, Option<String>),
+}
+
+/// RFC 5545 line unfolding: a CRLF (or LF) followed by a space or tab
+/// continues the previous line.
+fn unfold(ics: &str) -> Vec<String> {
+    let mut lines: Vec<String> = Vec::new();
+    for raw in ics.split('\n') {
+        let raw = raw.strip_suffix('\r').unwrap_or(raw);
+        if let Some(rest) = raw.strip_prefix(' ').or_else(|| raw.strip_prefix('\t')) {
+            if let Some(last) = lines.last_mut() {
+                last.push_str(rest);
+                continue;
+            }
+        }
+        lines.push(raw.to_string());
+    }
+    lines
+}
+
+/// Split a content line at the first ':' outside double quotes.
+fn split_content_line(line: &str) -> Option<(&str, &str)> {
+    let mut in_quotes = false;
+    for (i, c) in line.char_indices() {
+        match c {
+            '"' => in_quotes = !in_quotes,
+            ':' if !in_quotes => return Some((&line[..i], &line[i + 1..])),
+            _ => {}
+        }
+    }
+    None
+}
+
+fn unescape_text(v: &str) -> String {
+    let mut out = String::with_capacity(v.len());
+    let mut chars = v.chars();
+    while let Some(c) = chars.next() {
+        if c != '\\' {
+            out.push(c);
+            continue;
+        }
+        match chars.next() {
+            Some('n') | Some('N') => out.push(' '),
+            Some(other) => out.push(other),
+            None => {}
+        }
+    }
+    out
+}
+
+fn parse_dt(name_and_params: &str, value: &str) -> Option<DtValue> {
+    let mut tzid = None;
+    let mut is_date = false;
+    for param in name_and_params.split(';').skip(1) {
+        let (k, v) = param.split_once('=').unwrap_or((param, ""));
+        match k.to_ascii_uppercase().as_str() {
+            "TZID" => tzid = Some(v.trim_matches('"').to_string()),
+            "VALUE" if v.eq_ignore_ascii_case("DATE") => is_date = true,
+            _ => {}
+        }
+    }
+    let value = value.trim();
+    if is_date || value.len() == 8 {
+        return NaiveDate::parse_from_str(value, "%Y%m%d").ok().map(DtValue::Date);
+    }
+    if let Some(stripped) = value.strip_suffix('Z') {
+        let ndt = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
+        return Some(DtValue::Utc(Utc.from_utc_datetime(&ndt)));
+    }
+    let ndt = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S").ok()?;
+    Some(DtValue::Zoned(ndt, tzid))
+}
+
+fn parse_ics_events(ics: &str) -> Vec<VEvent> {
+    let mut events = Vec::new();
+    let mut current: Option<VEvent> = None;
+    for line in unfold(ics) {
+        let Some((head, value)) = split_content_line(&line) else { continue };
+        let name = head.split(';').next().unwrap_or("").to_ascii_uppercase();
+        match name.as_str() {
+            "BEGIN" if value.eq_ignore_ascii_case("VEVENT") => {
+                current = Some(VEvent::default());
+            }
+            "END" if value.eq_ignore_ascii_case("VEVENT") => {
+                if let Some(ev) = current.take() {
+                    events.push(ev);
+                }
+            }
+            _ => {
+                let Some(ev) = current.as_mut() else { continue };
+                match name.as_str() {
+                    "UID" => ev.uid = value.trim().to_string(),
+                    "SUMMARY" => ev.summary = unescape_text(value.trim()),
+                    "DTSTART" => ev.dtstart = parse_dt(head, value),
+                    "DTEND" => ev.dtend = parse_dt(head, value),
+                    "RRULE" | "RDATE" => ev.has_rrule = true,
+                    "STATUS" => ev.cancelled = value.trim().eq_ignore_ascii_case("CANCELLED"),
+                    _ => {}
+                }
+            }
+        }
+    }
+    events
+}
+
+// ── VEvent → records ──────────────────────────────────────────────────────
+
+fn to_local(dt: &DtValue) -> (NaiveDate, Option<(u32, u32)>) {
+    use chrono::Timelike;
+    match dt {
+        DtValue::Date(d) => (*d, None),
+        DtValue::Utc(dt) => {
+            let local = dt.with_timezone(&Local);
+            (local.date_naive(), Some((local.time().hour(), local.time().minute())))
+        }
+        DtValue::Zoned(ndt, tzid) => {
+            let converted = tzid
+                .as_deref()
+                .and_then(|id| chrono_tz::Tz::from_str(id).ok())
+                .and_then(|tz| tz.from_local_datetime(ndt).earliest())
+                .map(|dt| dt.with_timezone(&Local).naive_local());
+            if converted.is_none() && tzid.is_some() {
+                log::warn!("unknown TZID {:?}; treating as local time", tzid.as_deref().unwrap());
+            }
+            let ndt = converted.unwrap_or(*ndt);
+            (ndt.date(), Some((ndt.time().hour(), ndt.time().minute())))
+        }
+    }
+}
+
+fn event_to_records(
+    ev: &VEvent,
+    window: (NaiveDate, NaiveDate),
+    source: &str,
+    seen: &mut BTreeSet<(String, Option<String>, String)>,
+    out: &mut Vec<EventRecord>,
+) {
+    if ev.cancelled {
+        return;
+    }
+    let Some(dtstart) = &ev.dtstart else { return };
+    let title = if ev.summary.is_empty() { "(untitled)".to_string() } else { ev.summary.clone() };
+    let uid = (!ev.uid.is_empty()).then(|| ev.uid.clone());
+
+    let mut push = |date: NaiveDate, time: Option<(u32, u32)>| {
+        if date < window.0 || date > window.1 {
+            return;
+        }
+        let date_s = date.to_string();
+        let time_s = time.map(|(h, m)| format!("{h:02}:{m:02}"));
+        // Expanded instances of one event share a UID; the (date, time,
+        // title) key is what makes each day's mirror record unique.
+        if seen.insert((date_s.clone(), time_s.clone(), title.clone())) {
+            out.push(EventRecord {
+                date: date_s,
+                time: time_s,
+                title: title.clone(),
+                uid: uid.clone(),
+                source: Some(source.to_string()),
+            });
+        }
+    };
+
+    match to_local(dtstart) {
+        (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.
+            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),
+            };
+            let mut day = start;
+            let mut span = 0;
+            while day < end && span < MAX_ALLDAY_SPAN {
+                push(day, None);
+                let Some(next) = day.checked_add_days(Days::new(1)) else { break };
+                day = next;
+                span += 1;
+            }
+        }
+    }
+}
+
+// ── 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 {
+        EventRecord {
+            date: date.into(),
+            time: None,
+            title: title.into(),
+            uid: None,
+            source: source.map(String::from),
+        }
+    }
+
+    #[test]
+    fn unfolds_continuation_lines() {
+        let lines = unfold("SUMMARY:split\r\n  over\r\nUID:x\n\tmore");
+        assert_eq!(lines, vec!["SUMMARY:split over", "UID:xmore"]);
+    }
+
+    #[test]
+    fn parses_expanded_utc_event() {
+        let ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:abc\r\nDTSTART:20260901T140000Z\r\nDTEND:20260901T150000Z\r\nSUMMARY:Dentist\\, checkup\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
+        let events = parse_ics_events(ics);
+        assert_eq!(events.len(), 1);
+        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);
+    }
+
+    #[test]
+    fn all_day_span_yields_one_record_per_day_dtend_exclusive() {
+        let ev = VEvent {
+            uid: "trip".into(),
+            summary: "Trip".into(),
+            dtstart: Some(DtValue::Date(NaiveDate::from_ymd_opt(2026, 9, 10).unwrap())),
+            dtend: Some(DtValue::Date(NaiveDate::from_ymd_opt(2026, 9, 13).unwrap())),
+            ..Default::default()
+        };
+        let window = (
+            NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(),
+            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);
+        assert_eq!(
+            out.iter().map(|r| r.date.as_str()).collect::<Vec<_>>(),
+            vec!["2026-09-10", "2026-09-11", "2026-09-12"]
+        );
+        assert!(out.iter().all(|r| r.time.is_none() && r.source.as_deref() == Some("icloud:x")));
+    }
+
+    #[test]
+    fn cancelled_events_are_dropped() {
+        let ics = "BEGIN:VEVENT\r\nUID:x\r\nSTATUS:CANCELLED\r\nDTSTART:20260901T140000Z\r\nEND:VEVENT\r\n";
+        let ev = &parse_ics_events(ics)[0];
+        let (mut seen, mut out) = (BTreeSet::new(), Vec::new());
+        let window = (
+            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);
+        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"]);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..b45b07b
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,53 @@
+//! 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.
+
+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)]
+pub struct EventRecord {
+    pub date: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub time: Option<String>,
+    pub title: String,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub uid: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub source: Option<String>,
+}
+
+pub fn data_path() -> 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")
+}
+
+pub fn load_records() -> std::io::Result<Vec<EventRecord>> {
+    let text = match std::fs::read_to_string(data_path()) {
+        Ok(t) => t,
+        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
+        Err(e) => return Err(e),
+    };
+    serde_json::from_str(&text).map_err(std::io::Error::other)
+}
+
+/// 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();
+    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)
+}
diff --git a/src/main.rs b/src/main.rs
index b29d88b..b698fb6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,10 @@
 //!
 //! A 6×7 month grid on the left, a day pane on the right. Events live in
 //! `$XDG_DATA_HOME/cce/calendar/events.json` (one flat list of
-//! date/time/title records) and are saved on every mutation.
+//! 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.
 //!
 //! 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,8 +16,8 @@
 //! (default monday).
 
 use std::collections::BTreeMap;
-use std::path::PathBuf;
 
+use cce_calendar::{load_records, save_records, EventRecord};
 use chrono::{Datelike, Days, Local, NaiveDate, Weekday};
 use wayland_client::QueueHandle;
 
@@ -36,6 +39,8 @@ const SIDEBAR_BG: [f32; 4] = [0.10, 0.105, 0.12, 1.0];
 const GRID_LINE: [f32; 4] = [1.0, 1.0, 1.0, 0.06];
 const ACCENT: [f32; 4] = [0.22, 0.42, 0.85, 1.0];
 const EVENT_DOT: [f32; 4] = [0.95, 0.72, 0.30, 1.0];
+/// Dot for events mirrored from a remote calendar (`source` set).
+const SYNC_DOT: [f32; 4] = [0.42, 0.68, 0.95, 1.0];
 const ROW_SEL: [f32; 4] = [1.0, 1.0, 1.0, 0.08];
 const TEXT: [u8; 3] = [225, 228, 232];
 const TEXT_DIM: [u8; 3] = [140, 145, 152];
@@ -48,39 +53,19 @@ enum Message {
 }
 
 /// One event on a day. `time` is (hour, minute); untimed events sort after
-/// timed ones.
+/// timed ones. `uid`/`source` ride along from synced records so a save from
+/// this app never strips what `cce-calendar-sync` wrote.
 #[derive(Clone, Debug)]
 struct Event {
     time: Option<(u32, u32)>,
     title: String,
-}
-
-/// The on-disk shape: a flat list keeps the file trivially mergeable and
-/// greppable.
-#[derive(serde::Serialize, serde::Deserialize)]
-struct EventRecord {
-    date: String,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    time: Option<String>,
-    title: String,
-}
-
-fn data_path() -> 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")
+    uid: Option<String>,
+    source: Option<String>,
 }
 
 fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
     let mut map: BTreeMap<NaiveDate, Vec<Event>> = BTreeMap::new();
-    let Ok(text) = std::fs::read_to_string(data_path()) else {
-        return map;
-    };
-    let records: Vec<EventRecord> = match serde_json::from_str(&text) {
+    let records = match load_records() {
         Ok(r) => r,
         Err(e) => {
             log::error!("events.json unreadable, starting empty: {e}");
@@ -92,7 +77,12 @@ fn load_events() -> BTreeMap<NaiveDate, Vec<Event>> {
             continue;
         };
         let time = rec.time.as_deref().and_then(parse_time);
-        map.entry(date).or_default().push(Event { time, title: rec.title });
+        map.entry(date).or_default().push(Event {
+            time,
+            title: rec.title,
+            uid: rec.uid,
+            source: rec.source,
+        });
     }
     for events in map.values_mut() {
         sort_events(events);
@@ -125,7 +115,7 @@ fn parse_event(raw: &str) -> Option<Event> {
         },
     };
     let title = if title.is_empty() { "(untitled)".to_string() } else { title };
-    Some(Event { time, title })
+    Some(Event { time, title, uid: None, source: None })
 }
 
 fn week_start_config() -> Weekday {
@@ -286,17 +276,12 @@ impl CalendarApp {
                     date: date.to_string(),
                     time: e.time.map(|(h, m)| format!("{h:02}:{m:02}")),
                     title: e.title.clone(),
+                    uid: e.uid.clone(),
+                    source: e.source.clone(),
                 })
             })
             .collect();
-        let path = data_path();
-        let write = || -> std::io::Result<()> {
-            if let Some(dir) = path.parent() {
-                std::fs::create_dir_all(dir)?;
-            }
-            std::fs::write(&path, serde_json::to_string_pretty(&records).unwrap_or_default())
-        };
-        self.status = write().err().map(|e| format!("save failed: {e}"));
+        self.status = save_records(&records).err().map(|e| format!("save failed: {e}"));
     }
 
     fn commit_input(&mut self) {
@@ -468,7 +453,8 @@ impl CalendarApp {
                 pc.clip(cell, |pc| {
                     let mut y = cell.y + 24.0;
                     for e in events.iter().take(shown) {
-                        pc.circle(cell.x + 9.0, y + 6.0, 2.5, EVENT_DOT);
+                        let dot = if e.source.is_some() { SYNC_DOT } else { EVENT_DOT };
+                        pc.circle(cell.x + 9.0, y + 6.0, 2.5, dot);
                         let alpha = if in_month { TEXT } else { TEXT_DIM };
                         pc.text_with(e.title.clone(), cell.x + 15.0, y, 10.0, alpha, None,
                             Some([cell.x, cell.y, cell.x + cell.width - 4.0, cell.y + cell.height]));