calendar
git clone https://git.lucas.co/cce-calendar.git
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]));