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

src/bin/sync.rs (58.7K)

   1 //! `cce-calendar-sync` — two-way sync between remote calendars and
   2 //! cce-calendar's `events.json`.
   3 //!
   4 //! Accounts come from the same `accounts.json` cce-mail reads (owned by
   5 //! cce-system-interface). Two kinds are synced:
   6 //!
   7 //! - **iCloud**, over CalDAV, with the password from the `cce-mail` keyring
   8 //!   service — an Apple app-specific password is valid for CalDAV as well as
   9 //!   IMAP, so the credential that fetches mail fetches the calendar too.
  10 //! - **Google**, over the Calendar REST API, with the OAuth tokens the
  11 //!   settings app's Google sign-in stores (`calendar.readonly` to read,
  12 //!   `calendar.events` to write). Google's CalDAV endpoint refuses app
  13 //!   passwords, hence the API. The access token is refreshed in memory each
  14 //!   run and never written back: accounts.json has enough writers already.
  15 //!
  16 //! Reading is a mirror: each run replaces the records whose `source` matches
  17 //! the account ("icloud:<email>" / "google:<email>"), with recurrences
  18 //! expanded server-side (CalDAV `<C:expand>`, Google `singleEvents=true`)
  19 //! and marked `recurring`. Writing is a three-way merge against
  20 //! `sync-state.json`, the last-synced (date, time, title, etag) per
  21 //! NON-recurring event: a record typed in the app (no `source`) is created
  22 //! on the default calendar (`push-to` in the app's config.kdl; iCloud when
  23 //! unset and such an account exists) and comes back carrying its identity;
  24 //! a tracked event missing from the file is deleted on the server; one whose
  25 //! date, time or title differs from the state is updated there (local wins
  26 //! over a simultaneous remote edit; the next tick reconciles). Instances of
  27 //! recurring events are never written: the mirror cannot say "just this
  28 //! one", so a deleted instance simply reappears. Guards: a missing
  29 //! events.json re-imports rather than deletes, and a run that would delete
  30 //! most tracked events (>5 and >50%) refuses without `--force-deletes`.
  31 //!
  32 //! CalDAV updates PATCH the fetched iCalendar (SUMMARY, DTSTART, DTEND)
  33 //! rather than rebuilding it, so alarms and Apple's own properties survive;
  34 //! Google updates are field-level PATCHes with If-Match.
  35 //!
  36 //! Usage: `cce-calendar-sync [--dry-run] [--force-deletes]`. Driven by
  37 //! cce-calendar-sync.timer; harmless to run by hand. Exits nonzero if any
  38 //! account failed (the timer just tries again next tick); other accounts'
  39 //! results are still written.
  40 
  41 use std::collections::{BTreeMap, BTreeSet};
  42 use std::str::FromStr;
  43 
  44 use cce_calendar::{
  45     data_path, load_records, load_sync_state, push_target_config, save_records, save_sync_state,
  46     sort_records, EventRecord, PushTarget, SyncState, SyncedEvent,
  47 };
  48 use chrono::{DateTime, Days, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
  49 
  50 const CALDAV_ROOT: &str = "https://caldav.icloud.com/";
  51 /// Sync window around today. Wide enough forward that "next spring" plans
  52 /// show up; bounded so the file stays a glanceable flat list.
  53 const PAST_DAYS: u64 = 60;
  54 const FUTURE_DAYS: u64 = 400;
  55 /// An all-day event spanning more than this is almost certainly bad data
  56 /// (a botched DTEND); clamp rather than flood two months of cells.
  57 const MAX_ALLDAY_SPAN: u64 = 62;
  58 /// A timed event typed in the app has no end; it is created an hour long.
  59 const DEFAULT_DURATION_MIN: i64 = 60;
  60 
  61 const CALDAV_NS: &str = "urn:ietf:params:xml:ns:caldav";
  62 
  63 fn main() {
  64     env_logger::init();
  65     let args: Vec<String> = std::env::args().collect();
  66     let dry_run = args.iter().any(|a| a == "--dry-run");
  67     let force_deletes = args.iter().any(|a| a == "--force-deletes");
  68 
  69     let (icloud, google) = match (icloud_accounts(), google_accounts()) {
  70         (Ok(i), Ok(g)) => (i, g),
  71         (Err(e), _) | (_, Err(e)) => {
  72             log::error!("cannot read accounts: {e}");
  73             std::process::exit(1);
  74         }
  75     };
  76     if icloud.is_empty() && google.is_empty() {
  77         log::info!("no iCloud or Google (OAuth) accounts in accounts.json; nothing to sync");
  78         return;
  79     }
  80 
  81     let today = Local::now().date_naive();
  82     let window = (
  83         today.checked_sub_days(Days::new(PAST_DAYS)).unwrap_or(today),
  84         today.checked_add_days(Days::new(FUTURE_DAYS)).unwrap_or(today),
  85     );
  86 
  87     // Typed events go to one calendar; the account kind that owns it.
  88     let push = push_target_config().unwrap_or_else(|| PushTarget {
  89         kind: if !icloud.is_empty() { "icloud" } else { "google" }.to_string(),
  90         calendar: None,
  91     });
  92 
  93     let had_file = data_path().exists();
  94     let mut records = match load_records() {
  95         Ok(r) => r,
  96         Err(e) => {
  97             // Refuse to rewrite a file we could not read — that would
  98             // silently drop every hand-entered event.
  99             log::error!("events.json unreadable, not writing: {e}");
 100             std::process::exit(1);
 101         }
 102     };
 103     let mut state = match load_sync_state() {
 104         Ok(s) => s,
 105         Err(e) => {
 106             log::error!("sync-state.json unreadable: {e}");
 107             std::process::exit(1);
 108         }
 109     };
 110     if !had_file && !state.events.is_empty() {
 111         // The file is gone (fresh clone, deleted). Re-import rather than
 112         // reading absence as "delete everything on the server".
 113         log::warn!("events.json missing; discarding sync state and re-importing");
 114         state = SyncState::default();
 115     }
 116 
 117     let mut failed = false;
 118     let mut synced_any = false;
 119     let backends: Vec<Backend> = icloud
 120         .into_iter()
 121         .map(Backend::ICloud)
 122         .chain(google.into_iter().map(Backend::Google))
 123         .collect();
 124     for (i, backend) in backends.iter().enumerate() {
 125         let push_here = push.kind == backend.kind()
 126             && backends.iter().position(|b| b.kind() == push.kind) == Some(i);
 127         match sync_source(backend, window, &push, push_here, &mut records, &mut state, dry_run, force_deletes)
 128         {
 129             Ok(()) => synced_any = true,
 130             Err(e) => {
 131                 log::error!("{}: sync failed, keeping existing records: {e}", backend.email());
 132                 failed = true;
 133             }
 134         }
 135     }
 136 
 137     if synced_any && !dry_run {
 138         sort_records(&mut records);
 139         if let Err(e) = save_records(&records) {
 140             log::error!("saving events.json failed: {e}");
 141             failed = true;
 142         }
 143         if let Err(e) = save_sync_state(&state) {
 144             log::error!("saving sync-state.json failed: {e}");
 145             failed = true;
 146         }
 147     }
 148     if failed {
 149         std::process::exit(1);
 150     }
 151 }
 152 
 153 enum Backend {
 154     ICloud(Account),
 155     Google(GoogleAccount),
 156 }
 157 
 158 impl Backend {
 159     fn email(&self) -> &str {
 160         match self {
 161             Backend::ICloud(a) => &a.email,
 162             Backend::Google(a) => &a.email,
 163         }
 164     }
 165     fn kind(&self) -> &'static str {
 166         match self {
 167             Backend::ICloud(_) => "icloud",
 168             Backend::Google(_) => "google",
 169         }
 170     }
 171     fn source(&self) -> String {
 172         format!("{}:{}", self.kind(), self.email())
 173     }
 174 }
 175 
 176 // ── Remote model (both backends produce it) ───────────────────────────────
 177 
 178 struct RemoteCalendar {
 179     /// Where a new event is created (CalDAV collection URL / Google
 180     /// calendar id).
 181     target: String,
 182     name: String,
 183     primary: bool,
 184 }
 185 
 186 struct RemoteEvent {
 187     url: reqwest::Url,
 188     etag: String,
 189     recurring: bool,
 190     /// Every dated instance in the window, ready for the file.
 191     instances: Vec<EventRecord>,
 192     /// Unfolded logical lines of the resource (CalDAV only), for
 193     /// patch-and-PUT.
 194     lines: Vec<String>,
 195 }
 196 
 197 struct Remote {
 198     calendars: Vec<RemoteCalendar>,
 199     events: BTreeMap<String, RemoteEvent>,
 200 }
 201 
 202 // ── One account ───────────────────────────────────────────────────────────
 203 
 204 #[allow(clippy::too_many_arguments)]
 205 fn sync_source(
 206     backend: &Backend,
 207     window: (NaiveDate, NaiveDate),
 208     push: &PushTarget,
 209     push_here: bool,
 210     records: &mut Vec<EventRecord>,
 211     state: &mut SyncState,
 212     dry_run: bool,
 213     force_deletes: bool,
 214 ) -> Result<(), String> {
 215     let client = reqwest::blocking::Client::builder()
 216         .timeout(std::time::Duration::from_secs(60))
 217         .build()
 218         .map_err(|e| e.to_string())?;
 219     let source = backend.source();
 220     let session = match backend {
 221         Backend::ICloud(_) => Session::ICloud,
 222         Backend::Google(acc) => Session::Google(google_access_token(&client, acc)?),
 223     };
 224     let ops = Ops { client: &client, backend, session: &session };
 225     let mut remote = ops.fetch(window, &source)?;
 226     log::info!(
 227         "{}: {} calendar(s), {} event(s) in window",
 228         backend.email(),
 229         remote.calendars.len(),
 230         remote.events.len()
 231     );
 232 
 233     // ── Plan ───────────────────────────────────────────────────────────
 234     let local_by_uid: BTreeMap<&str, &EventRecord> = records
 235         .iter()
 236         .filter(|r| r.source.as_deref() == Some(&source))
 237         .filter_map(|r| r.uid.as_deref().map(|u| (u, r)))
 238         .collect();
 239     let mut push_deletes: Vec<String> = Vec::new();
 240     let mut push_updates: Vec<(String, EventRecord)> = Vec::new();
 241     let mut forget: Vec<String> = Vec::new();
 242     for (uid, s) in state.events.iter().filter(|(_, s)| s.source == source) {
 243         match remote.events.get(uid) {
 244             None => forget.push(uid.clone()),
 245             Some(r) if r.recurring => forget.push(uid.clone()),
 246             Some(_) => match local_by_uid.get(uid.as_str()) {
 247                 None => push_deletes.push(uid.clone()),
 248                 Some(l) if !s.matches(l) => push_updates.push((uid.clone(), (*l).clone())),
 249                 Some(_) => {}
 250             },
 251         }
 252     }
 253     let push_creates: Vec<usize> = if push_here {
 254         records
 255             .iter()
 256             .enumerate()
 257             .filter(|(_, r)| r.source.is_none() && r.uid.is_none())
 258             .map(|(i, _)| i)
 259             .collect()
 260     } else {
 261         Vec::new()
 262     };
 263 
 264     let tracked = state.events.values().filter(|s| s.source == source).count();
 265     if !force_deletes && push_deletes.len() > 5 && push_deletes.len() * 2 > tracked {
 266         return Err(format!(
 267             "refusing to delete {} of {tracked} tracked events on the server — if they were \
 268              really removed on purpose, run cce-calendar-sync --force-deletes",
 269             push_deletes.len()
 270         ));
 271     }
 272     log::info!(
 273         "{}: push {} new / {} changed / {} deleted",
 274         backend.email(),
 275         push_creates.len(),
 276         push_updates.len(),
 277         push_deletes.len()
 278     );
 279     let target = if push_here { Some(ops.pick_target(&remote.calendars, push)?) } else { None };
 280     if let Some(t) = &target {
 281         if !push_creates.is_empty() {
 282             log::info!("{}: new events go to {}", backend.email(), t.name);
 283         }
 284     }
 285     if dry_run {
 286         for i in &push_creates {
 287             let r = &records[*i];
 288             println!("push new:    {} {} {}", r.date, r.time.as_deref().unwrap_or("-----"), r.title);
 289         }
 290         for (uid, r) in &push_updates {
 291             println!("push change: {} {} {} ({uid})", r.date, r.time.as_deref().unwrap_or("-----"), r.title);
 292         }
 293         for uid in &push_deletes {
 294             println!("push delete: {} ({uid})", state.events[uid].title);
 295         }
 296         let mirrored: usize = remote.events.values().map(|e| e.instances.len()).sum();
 297         println!("{source}: {mirrored} record(s) mirrored");
 298         return Ok(());
 299     }
 300 
 301     // ── Server side ────────────────────────────────────────────────────
 302     for uid in &push_deletes {
 303         let s = &state.events[uid];
 304         let url = reqwest::Url::parse(&s.url).map_err(|e| e.to_string())?;
 305         match ops.delete(&url, &s.etag) {
 306             Ok(()) => {
 307                 state.events.remove(uid);
 308                 remote.events.remove(uid);
 309             }
 310             Err(e) => log::warn!("push delete {uid} failed (will retry next tick): {e}"),
 311         }
 312     }
 313     for (uid, local) in &push_updates {
 314         let Some(r) = remote.events.get_mut(uid) else { continue };
 315         match ops.update(r, local) {
 316             Ok(etag) => {
 317                 r.etag = etag.clone();
 318                 r.instances = vec![EventRecord {
 319                     uid: Some(uid.clone()),
 320                     source: Some(source.clone()),
 321                     recurring: false,
 322                     ..local.clone()
 323                 }];
 324                 state.events.insert(uid.clone(), synced(&source, &r.url, etag, local));
 325             }
 326             Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
 327         }
 328     }
 329     let mut consumed: BTreeSet<usize> = BTreeSet::new();
 330     if let Some(t) = &target {
 331         for i in &push_creates {
 332             let local = &records[*i];
 333             match ops.create(t, local) {
 334                 Ok((uid, url, etag)) => {
 335                     state.events.insert(uid.clone(), synced(&source, &url, etag.clone(), local));
 336                     remote.events.insert(
 337                         uid.clone(),
 338                         RemoteEvent {
 339                             url,
 340                             etag,
 341                             recurring: false,
 342                             instances: vec![EventRecord {
 343                                 uid: Some(uid),
 344                                 source: Some(source.clone()),
 345                                 recurring: false,
 346                                 ..local.clone()
 347                             }],
 348                             lines: Vec::new(),
 349                         },
 350                     );
 351                     consumed.insert(*i);
 352                 }
 353                 Err(e) => log::warn!("push create {:?} failed (will retry next tick): {e}", local.title),
 354             }
 355         }
 356     }
 357 
 358     // ── State: every non-recurring event the server now holds ─────────
 359     for uid in &forget {
 360         state.events.remove(uid);
 361     }
 362     for (uid, r) in &remote.events {
 363         if r.recurring {
 364             continue;
 365         }
 366         if let Some(first) = r.instances.first() {
 367             state.events.insert(uid.clone(), synced(&source, &r.url, r.etag.clone(), first));
 368         }
 369     }
 370 
 371     // ── File: this source's records are the fresh mirror ──────────────
 372     let mut idx = 0;
 373     records.retain(|r| {
 374         let keep = r.source.as_deref() != Some(&source) && !consumed.contains(&idx);
 375         idx += 1;
 376         keep
 377     });
 378     for r in remote.events.values() {
 379         records.extend(r.instances.iter().cloned());
 380     }
 381     Ok(())
 382 }
 383 
 384 fn synced(source: &str, url: &reqwest::Url, etag: String, r: &EventRecord) -> SyncedEvent {
 385     SyncedEvent {
 386         source: source.to_string(),
 387         url: url.to_string(),
 388         etag,
 389         date: r.date.clone(),
 390         time: r.time.clone(),
 391         title: r.title.clone(),
 392     }
 393 }
 394 
 395 /// Per-run credentials the requests need beyond the account itself.
 396 enum Session {
 397     ICloud,
 398     Google(String),
 399 }
 400 
 401 /// The backend operations, bundled so the pass reads the same either way.
 402 struct Ops<'a> {
 403     client: &'a reqwest::blocking::Client,
 404     backend: &'a Backend,
 405     session: &'a Session,
 406 }
 407 
 408 impl Ops<'_> {
 409     fn token(&self) -> &str {
 410         match self.session {
 411             Session::Google(t) => t,
 412             Session::ICloud => "",
 413         }
 414     }
 415 
 416     fn fetch(&self, window: (NaiveDate, NaiveDate), source: &str) -> Result<Remote, String> {
 417         match self.backend {
 418             Backend::ICloud(acc) => fetch_icloud(self.client, acc, window, source),
 419             Backend::Google(acc) => fetch_google(self.client, self.token(), acc, window, source),
 420         }
 421     }
 422 
 423     /// The calendar typed events are created on: the configured name, else
 424     /// the account's primary (Google) or first (iCloud) event calendar.
 425     fn pick_target<'c>(
 426         &self,
 427         calendars: &'c [RemoteCalendar],
 428         push: &PushTarget,
 429     ) -> Result<&'c RemoteCalendar, String> {
 430         if let Some(name) = &push.calendar {
 431             return calendars
 432                 .iter()
 433                 .find(|c| c.name.eq_ignore_ascii_case(name))
 434                 .ok_or_else(|| format!("push-to calendar {name:?} not found on this account"));
 435         }
 436         calendars
 437             .iter()
 438             .find(|c| c.primary)
 439             .or_else(|| calendars.first())
 440             .ok_or_else(|| "no writable calendar on this account".to_string())
 441     }
 442 
 443     fn create(
 444         &self,
 445         target: &RemoteCalendar,
 446         r: &EventRecord,
 447     ) -> Result<(String, reqwest::Url, String), String> {
 448         match self.backend {
 449             Backend::ICloud(acc) => {
 450                 let uid = new_uid();
 451                 let base = reqwest::Url::parse(&target.target).map_err(|e| e.to_string())?;
 452                 let url = base.join(&format!("{uid}.ics")).map_err(|e| e.to_string())?;
 453                 let etag = put_ics(self.client, acc, &url, &new_vevent(&uid, r)?, None)?;
 454                 Ok((uid, url, etag))
 455             }
 456             Backend::Google(_) => google_create(self.client, self.token(), &target.target, r),
 457         }
 458     }
 459 
 460     fn update(&self, remote: &RemoteEvent, r: &EventRecord) -> Result<String, String> {
 461         match self.backend {
 462             Backend::ICloud(acc) => {
 463                 let body = patch_vevent(&remote.lines, r)?;
 464                 put_ics(self.client, acc, &remote.url, &body, Some(&remote.etag))
 465             }
 466             Backend::Google(_) => google_update(self.client, self.token(), &remote.url, &remote.etag, r),
 467         }
 468     }
 469 
 470     fn delete(&self, url: &reqwest::Url, etag: &str) -> Result<(), String> {
 471         match self.backend {
 472             Backend::ICloud(acc) => delete_ics(self.client, acc, url, etag),
 473             Backend::Google(_) => google_delete(self.client, self.token(), url),
 474         }
 475     }
 476 }
 477 
 478 // ── Time helpers ──────────────────────────────────────────────────────────
 479 
 480 fn parse_record_time(r: &EventRecord) -> Result<(NaiveDate, Option<(u32, u32)>), String> {
 481     let date = r.date.parse::<NaiveDate>().map_err(|e| format!("bad date {:?}: {e}", r.date))?;
 482     let time = match &r.time {
 483         None => None,
 484         Some(t) => {
 485             let (h, m) = t.split_once(':').ok_or_else(|| format!("bad time {t:?}"))?;
 486             Some((h.parse().map_err(|_| format!("bad time {t:?}"))?, m.parse().map_err(|_| format!("bad time {t:?}"))?))
 487         }
 488     };
 489     Ok((date, time))
 490 }
 491 
 492 /// A record's start (and default end) as local wall-clock instants.
 493 fn record_span(r: &EventRecord) -> Result<(DateTime<Local>, DateTime<Local>), String> {
 494     let (date, time) = parse_record_time(r)?;
 495     let (h, m) = time.unwrap_or((0, 0));
 496     let ndt = date.and_hms_opt(h, m, 0).ok_or("bad time")?;
 497     let start = Local
 498         .from_local_datetime(&ndt)
 499         .earliest()
 500         .ok_or_else(|| format!("{ndt} does not exist in the local timezone"))?;
 501     Ok((start, start + Duration::minutes(DEFAULT_DURATION_MIN)))
 502 }
 503 
 504 // ── Accounts ──────────────────────────────────────────────────────────────
 505 
 506 struct Account {
 507     email: String,
 508     password: String,
 509 }
 510 
 511 /// The subset of cce-mail's AccountInfo this helper needs. Unknown fields
 512 /// are ignored, so the two readers cannot drift apart.
 513 #[derive(serde::Deserialize)]
 514 struct AccountOnDisk {
 515     email: String,
 516     #[serde(default)]
 517     imap: String,
 518     #[serde(default)]
 519     password: String,
 520 }
 521 
 522 fn icloud_accounts() -> Result<Vec<Account>, String> {
 523     let path = cce_ui::config::cce_config_dir().join("accounts.json");
 524     let text = std::fs::read_to_string(&path)
 525         .map_err(|e| format!("{}: {e}", path.display()))?;
 526     let on_disk: Vec<AccountOnDisk> =
 527         serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
 528 
 529     let mut out = Vec::new();
 530     for acc in on_disk {
 531         if !is_icloud(&acc) {
 532             continue;
 533         }
 534         // Same resolution order as cce-mail: a plaintext on-disk password is
 535         // still valid pre-migration; an empty one lives in the keyring under
 536         // the "cce-mail" service. This helper only reads — migration into
 537         // the keyring stays cce-mail's job.
 538         let password = if !acc.password.is_empty() {
 539             acc.password.clone()
 540         } else {
 541             match keyring::Entry::new("cce-mail", &acc.email).and_then(|e| e.get_password()) {
 542                 Ok(p) => p,
 543                 Err(e) => {
 544                     log::warn!("{}: no password available ({e}); skipping", acc.email);
 545                     continue;
 546                 }
 547             }
 548         };
 549         out.push(Account { email: acc.email, password });
 550     }
 551     Ok(out)
 552 }
 553 
 554 fn is_icloud(acc: &AccountOnDisk) -> bool {
 555     let host = acc.imap.split(':').next().unwrap_or("");
 556     host.ends_with(".mail.me.com")
 557         || ["@icloud.com", "@me.com", "@mac.com"].iter().any(|d| acc.email.ends_with(d))
 558 }
 559 
 560 // ── Google (OAuth) ────────────────────────────────────────────────────────
 561 
 562 struct GoogleAccount {
 563     email: String,
 564     refresh_token: String,
 565     client_id: String,
 566     client_secret: String,
 567 }
 568 
 569 /// The OAuth fields the settings app's Google sign-in writes.
 570 #[derive(serde::Deserialize)]
 571 struct OAuthOnDisk {
 572     email: String,
 573     #[serde(default)]
 574     is_oauth: bool,
 575     #[serde(default)]
 576     refresh_token: Option<String>,
 577     #[serde(default)]
 578     client_id: Option<String>,
 579     #[serde(default)]
 580     client_secret: Option<String>,
 581 }
 582 
 583 #[derive(serde::Deserialize, Default)]
 584 struct GoogleClientConfig {
 585     #[serde(default)]
 586     client_id: String,
 587     #[serde(default)]
 588     client_secret: String,
 589 }
 590 
 591 fn google_accounts() -> Result<Vec<GoogleAccount>, String> {
 592     let dir = cce_ui::config::cce_config_dir();
 593     let path = dir.join("accounts.json");
 594     let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
 595     let on_disk: Vec<OAuthOnDisk> =
 596         serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
 597     // An account without its own pinned client credentials falls back to
 598     // the global template the settings app maintains.
 599     let template: GoogleClientConfig = std::fs::read_to_string(dir.join("google_client.json"))
 600         .ok()
 601         .and_then(|t| serde_json::from_str(&t).ok())
 602         .unwrap_or_default();
 603     let mut out = Vec::new();
 604     for acc in on_disk {
 605         if !acc.is_oauth {
 606             continue;
 607         }
 608         let Some(refresh_token) = acc.refresh_token.filter(|t| !t.is_empty()) else {
 609             log::warn!("{}: OAuth account without a refresh token; sign in again", acc.email);
 610             continue;
 611         };
 612         out.push(GoogleAccount {
 613             email: acc.email,
 614             refresh_token,
 615             client_id: acc.client_id.filter(|s| !s.is_empty()).unwrap_or(template.client_id.clone()),
 616             client_secret: acc
 617                 .client_secret
 618                 .filter(|s| !s.is_empty())
 619                 .unwrap_or(template.client_secret.clone()),
 620         });
 621     }
 622     Ok(out)
 623 }
 624 
 625 /// A fresh access token from the refresh grant. Tokens last an hour and a
 626 /// tick is one request burst, so refreshing every run is simpler than
 627 /// tracking expiry — and keeps this helper from writing accounts.json.
 628 fn google_access_token(
 629     client: &reqwest::blocking::Client,
 630     acc: &GoogleAccount,
 631 ) -> Result<String, String> {
 632     let resp = client
 633         .post("https://oauth2.googleapis.com/token")
 634         .form(&[
 635             ("client_id", acc.client_id.as_str()),
 636             ("client_secret", acc.client_secret.as_str()),
 637             ("refresh_token", acc.refresh_token.as_str()),
 638             ("grant_type", "refresh_token"),
 639         ])
 640         .send()
 641         .map_err(|e| format!("token refresh: {e}"))?;
 642     let status = resp.status();
 643     let body: serde_json::Value = resp.json().map_err(|e| format!("token refresh: {e}"))?;
 644     if !status.is_success() {
 645         // invalid_grant here means the refresh token was revoked or the
 646         // consent predates the calendar scope — a re-login fixes both.
 647         return Err(format!("token refresh: HTTP {status} {body}"));
 648     }
 649     body.get("access_token")
 650         .and_then(|v| v.as_str())
 651         .map(String::from)
 652         .ok_or_else(|| "token refresh: no access_token in response".to_string())
 653 }
 654 
 655 fn google_call(
 656     client: &reqwest::blocking::Client,
 657     token: &str,
 658     method: reqwest::Method,
 659     url: &str,
 660     query: &[(&str, &str)],
 661     body: Option<&serde_json::Value>,
 662     if_match: Option<&str>,
 663 ) -> Result<serde_json::Value, String> {
 664     let mut req = client.request(method.clone(), url).bearer_auth(token).query(query);
 665     if let Some(b) = body {
 666         req = req.json(b);
 667     }
 668     if let Some(e) = if_match.filter(|e| !e.is_empty()) {
 669         req = req.header("If-Match", e);
 670     }
 671     let resp = req.send().map_err(|e| format!("{method} {url}: {e}"))?;
 672     let status = resp.status();
 673     if status == reqwest::StatusCode::NO_CONTENT {
 674         return Ok(serde_json::Value::Null);
 675     }
 676     let text = resp.text().map_err(|e| format!("{method} {url}: {e}"))?;
 677     if !status.is_success() {
 678         return Err(format!("{method} {url}: HTTP {status} {text}"));
 679     }
 680     if text.trim().is_empty() {
 681         return Ok(serde_json::Value::Null);
 682     }
 683     serde_json::from_str(&text).map_err(|e| format!("{method} {url}: bad JSON: {e}"))
 684 }
 685 
 686 const CALENDAR_API: &str = "https://www.googleapis.com/calendar/v3";
 687 
 688 fn google_events_url(cal_id: &str) -> String {
 689     format!("{CALENDAR_API}/calendars/{}/events", urlencode(cal_id))
 690 }
 691 
 692 fn fetch_google(
 693     client: &reqwest::blocking::Client,
 694     token: &str,
 695     acc: &GoogleAccount,
 696     window: (NaiveDate, NaiveDate),
 697     source: &str,
 698 ) -> Result<Remote, String> {
 699     let get = |url: &str, q: &[(&str, &str)]| {
 700         google_call(client, token, reqwest::Method::GET, url, q, None, None)
 701     };
 702     // Only calendars the user keeps visible in Google's own UI (`selected`);
 703     // subscribed-but-hidden ones stay hidden here too.
 704     let list = get(
 705         &format!("{CALENDAR_API}/users/me/calendarList"),
 706         &[("fields", "items(id,summary,selected,deleted,primary,accessRole)")],
 707     )?;
 708     let calendars: Vec<RemoteCalendar> = list["items"]
 709         .as_array()
 710         .into_iter()
 711         .flatten()
 712         .filter(|c| c["selected"].as_bool().unwrap_or(false) && !c["deleted"].as_bool().unwrap_or(false))
 713         .filter_map(|c| {
 714             Some(RemoteCalendar {
 715                 target: c["id"].as_str()?.to_string(),
 716                 name: c["summary"].as_str().unwrap_or("?").to_string(),
 717                 primary: c["primary"].as_bool().unwrap_or(false),
 718             })
 719         })
 720         .collect();
 721     let _ = acc;
 722 
 723     let time_min = format!("{}T00:00:00Z", window.0);
 724     let time_max = format!("{}T00:00:00Z", window.1);
 725     let mut events: BTreeMap<String, RemoteEvent> = BTreeMap::new();
 726     let mut seen = BTreeSet::new();
 727     for cal in &calendars {
 728         let url = google_events_url(&cal.target);
 729         let mut page_token = String::new();
 730         loop {
 731             let mut query = vec![
 732                 ("singleEvents", "true"),
 733                 ("timeMin", time_min.as_str()),
 734                 ("timeMax", time_max.as_str()),
 735                 ("maxResults", "2500"),
 736                 ("fields", "nextPageToken,items(id,iCalUID,recurringEventId,etag,summary,status,start,end)"),
 737             ];
 738             if !page_token.is_empty() {
 739                 query.push(("pageToken", page_token.as_str()));
 740             }
 741             let page = get(&url, &query).map_err(|e| format!("calendar {}: {e}", cal.name))?;
 742             for item in page["items"].as_array().into_iter().flatten() {
 743                 let Some(ev) = google_event(item) else { continue };
 744                 let id = item["id"].as_str().unwrap_or("").to_string();
 745                 let uid = item["iCalUID"].as_str().map(String::from).unwrap_or_else(|| id.clone());
 746                 let recurring = item["recurringEventId"].is_string();
 747                 let mut instances = Vec::new();
 748                 event_to_records(&ev, window, source, recurring, &mut seen, &mut instances);
 749                 let entry = events.entry(uid).or_insert_with(|| RemoteEvent {
 750                     url: reqwest::Url::parse(&format!("{url}/{id}")).expect("valid url"),
 751                     etag: item["etag"].as_str().unwrap_or("").to_string(),
 752                     recurring,
 753                     instances: Vec::new(),
 754                     lines: Vec::new(),
 755                 });
 756                 entry.recurring |= recurring;
 757                 entry.instances.extend(instances);
 758             }
 759             match page["nextPageToken"].as_str() {
 760                 Some(t) if !t.is_empty() => page_token = t.to_string(),
 761                 _ => break,
 762             }
 763         }
 764     }
 765     // Cancelled instances leave an empty entry; drop those.
 766     events.retain(|_, e| !e.instances.is_empty());
 767     Ok(Remote { calendars, events })
 768 }
 769 
 770 /// Google's `{date}` / `{dateTime}` pair onto the same DtValue the iCal
 771 /// path produces, so both feed one `event_to_records`.
 772 fn google_event(item: &serde_json::Value) -> Option<VEvent> {
 773     let dt = |v: &serde_json::Value| -> Option<DtValue> {
 774         if let Some(d) = v["date"].as_str() {
 775             return NaiveDate::parse_from_str(d, "%Y-%m-%d").ok().map(DtValue::Date);
 776         }
 777         let s = v["dateTime"].as_str()?;
 778         DateTime::parse_from_rfc3339(s).ok().map(|t| DtValue::Utc(t.with_timezone(&Utc)))
 779     };
 780     Some(VEvent {
 781         uid: item["iCalUID"].as_str().unwrap_or("").to_string(),
 782         summary: item["summary"].as_str().unwrap_or("").to_string(),
 783         dtstart: Some(dt(&item["start"])?),
 784         dtend: dt(&item["end"]),
 785         cancelled: item["status"].as_str() == Some("cancelled"),
 786         has_rrule: false,
 787         has_recurrence_id: false,
 788     })
 789 }
 790 
 791 fn google_body(r: &EventRecord) -> Result<serde_json::Value, String> {
 792     let (date, time) = parse_record_time(r)?;
 793     let (start, end) = if time.is_some() {
 794         let (s, e) = record_span(r)?;
 795         (
 796             serde_json::json!({ "dateTime": s.to_rfc3339() }),
 797             serde_json::json!({ "dateTime": e.to_rfc3339() }),
 798         )
 799     } else {
 800         let next = date.checked_add_days(Days::new(1)).ok_or("date overflow")?;
 801         (
 802             serde_json::json!({ "date": date.to_string() }),
 803             serde_json::json!({ "date": next.to_string() }),
 804         )
 805     };
 806     Ok(serde_json::json!({ "summary": r.title, "start": start, "end": end }))
 807 }
 808 
 809 fn google_create(
 810     client: &reqwest::blocking::Client,
 811     token: &str,
 812     cal_id: &str,
 813     r: &EventRecord,
 814 ) -> Result<(String, reqwest::Url, String), String> {
 815     let body = google_body(r)?;
 816     let url = google_events_url(cal_id);
 817     let resp = google_call(client, token, reqwest::Method::POST, &url, &[], Some(&body), None)?;
 818     let id = resp["id"].as_str().ok_or("created event has no id")?.to_string();
 819     let uid = resp["iCalUID"].as_str().map(String::from).unwrap_or_else(|| id.clone());
 820     let event_url = reqwest::Url::parse(&format!("{url}/{id}")).map_err(|e| e.to_string())?;
 821     Ok((uid, event_url, resp["etag"].as_str().unwrap_or("").to_string()))
 822 }
 823 
 824 /// Field-level PATCH: summary, start, end — a description, attendees or
 825 /// reminders set in Google's own apps ride through untouched.
 826 fn google_update(
 827     client: &reqwest::blocking::Client,
 828     token: &str,
 829     url: &reqwest::Url,
 830     etag: &str,
 831     r: &EventRecord,
 832 ) -> Result<String, String> {
 833     let body = google_body(r)?;
 834     let resp = google_call(client, token, reqwest::Method::PATCH, url.as_str(), &[], Some(&body), Some(etag))?;
 835     Ok(resp["etag"].as_str().unwrap_or("").to_string())
 836 }
 837 
 838 fn google_delete(client: &reqwest::blocking::Client, token: &str, url: &reqwest::Url) -> Result<(), String> {
 839     match google_call(client, token, reqwest::Method::DELETE, url.as_str(), &[], None, None) {
 840         Ok(_) => Ok(()),
 841         // Already gone counts as done.
 842         Err(e) if e.contains("HTTP 404") || e.contains("HTTP 410") => Ok(()),
 843         Err(e) => Err(e),
 844     }
 845 }
 846 
 847 /// Calendar ids are email-like and go into the path; only the few
 848 /// characters that could break it need escaping.
 849 fn urlencode(s: &str) -> String {
 850     s.replace('%', "%25").replace('/', "%2F").replace('#', "%23").replace('?', "%3F").replace('@', "%40")
 851 }
 852 
 853 // ── CalDAV (iCloud) ───────────────────────────────────────────────────────
 854 
 855 fn fetch_icloud(
 856     client: &reqwest::blocking::Client,
 857     acc: &Account,
 858     window: (NaiveDate, NaiveDate),
 859     source: &str,
 860 ) -> Result<Remote, String> {
 861     let root = reqwest::Url::parse(CALDAV_ROOT).expect("static url");
 862     let principal = discover_href(
 863         client, acc, &root, "0",
 864         r#"<?xml version="1.0" encoding="utf-8"?>
 865 <propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>"#,
 866         "current-user-principal",
 867     )?;
 868     let home = discover_href(
 869         client, acc, &principal, "0",
 870         r#"<?xml version="1.0" encoding="utf-8"?>
 871 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><prop><C:calendar-home-set/></prop></propfind>"#,
 872         "calendar-home-set",
 873     )?;
 874     let calendars = list_event_calendars(client, acc, &home)?;
 875 
 876     let (start, end) = (
 877         format!("{}T000000Z", window.0.format("%Y%m%d")),
 878         format!("{}T000000Z", window.1.format("%Y%m%d")),
 879     );
 880     let mut events = BTreeMap::new();
 881     let mut seen = BTreeSet::new();
 882     for cal in &calendars {
 883         let cal_url = reqwest::Url::parse(&cal.target).map_err(|e| e.to_string())?;
 884         fetch_events(client, acc, &cal_url, &start, &end, window, source, &mut seen, &mut events)
 885             .map_err(|e| format!("calendar {}: {e}", cal.name))?;
 886     }
 887     Ok(Remote { calendars, events })
 888 }
 889 
 890 fn dav_request(
 891     client: &reqwest::blocking::Client,
 892     acc: &Account,
 893     method: &str,
 894     url: &reqwest::Url,
 895     depth: &str,
 896     body: &str,
 897 ) -> Result<String, String> {
 898     let resp = client
 899         .request(
 900             reqwest::Method::from_bytes(method.as_bytes()).expect("static method"),
 901             url.clone(),
 902         )
 903         .basic_auth(&acc.email, Some(&acc.password))
 904         .header("Depth", depth)
 905         .header("Content-Type", "application/xml; charset=utf-8")
 906         .body(body.to_string())
 907         .send()
 908         .map_err(|e| format!("{method} {url}: {e}"))?;
 909     let status = resp.status();
 910     let text = resp.text().map_err(|e| e.to_string())?;
 911     if !status.is_success() {
 912         // 401 here usually means the app-specific password predates 2FA or
 913         // was revoked — generating a fresh one on appleid.apple.com fixes it.
 914         return Err(format!("{method} {url}: HTTP {status}"));
 915     }
 916     Ok(text)
 917 }
 918 
 919 /// PROPFIND for a single href-valued property (principal, calendar home).
 920 fn discover_href(
 921     client: &reqwest::blocking::Client,
 922     acc: &Account,
 923     url: &reqwest::Url,
 924     depth: &str,
 925     body: &str,
 926     prop: &str,
 927 ) -> Result<reqwest::Url, String> {
 928     let xml = dav_request(client, acc, "PROPFIND", url, depth, body)?;
 929     let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
 930     let href = doc
 931         .descendants()
 932         .find(|n| n.tag_name().name() == prop)
 933         .and_then(|n| n.descendants().find(|c| c.tag_name().name() == "href"))
 934         .and_then(|n| n.text())
 935         .ok_or_else(|| format!("no {prop} in PROPFIND response"))?;
 936     url.join(href.trim()).map_err(|e| format!("bad {prop} href {href:?}: {e}"))
 937 }
 938 
 939 /// Depth-1 PROPFIND on the calendar home: the child collections that are
 940 /// calendars and hold VEVENTs (Reminders lists are VTODO-only and excluded —
 941 /// they are cce-list's).
 942 fn list_event_calendars(
 943     client: &reqwest::blocking::Client,
 944     acc: &Account,
 945     home: &reqwest::Url,
 946 ) -> Result<Vec<RemoteCalendar>, String> {
 947     let body = r#"<?xml version="1.0" encoding="utf-8"?>
 948 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
 949   <prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>
 950 </propfind>"#;
 951     let xml = dav_request(client, acc, "PROPFIND", home, "1", body)?;
 952     let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
 953 
 954     let mut out = Vec::new();
 955     for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
 956         let Some(href) = resp
 957             .children()
 958             .find(|c| c.tag_name().name() == "href")
 959             .and_then(|n| n.text())
 960         else {
 961             continue;
 962         };
 963         let is_calendar = resp.descendants().any(|n| {
 964             n.tag_name().name() == "calendar" && n.tag_name().namespace() == Some(CALDAV_NS)
 965         });
 966         if !is_calendar {
 967             continue;
 968         }
 969         // If the server states the component set, require VEVENT; if the
 970         // property is absent (404 propstat), assume events.
 971         let comps: Vec<_> = resp
 972             .descendants()
 973             .filter(|n| n.tag_name().name() == "comp")
 974             .filter_map(|n| n.attribute("name"))
 975             .collect();
 976         if !comps.is_empty() && !comps.contains(&"VEVENT") {
 977             continue;
 978         }
 979         let name = resp
 980             .descendants()
 981             .find(|n| n.tag_name().name() == "displayname")
 982             .and_then(|n| n.text())
 983             .unwrap_or(href)
 984             .to_string();
 985         let url = home
 986             .join(href.trim())
 987             .map_err(|e| format!("bad calendar href {href:?}: {e}"))?;
 988         if url.path().trim_end_matches('/') == home.path().trim_end_matches('/') {
 989             continue; // the home collection lists itself first
 990         }
 991         out.push(RemoteCalendar { target: url.to_string(), name, primary: false });
 992     }
 993     Ok(out)
 994 }
 995 
 996 fn calendar_query(start: &str, end: &str, expand: bool) -> String {
 997     let data = if expand {
 998         format!(r#"<C:calendar-data><C:expand start="{start}" end="{end}"/></C:calendar-data>"#)
 999     } else {
1000         "<C:calendar-data/>".to_string()
1001     };
1002     format!(
1003         r#"<?xml version="1.0" encoding="utf-8"?>
1004 <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1005   <D:prop><D:getetag/>{data}</D:prop>
1006   <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
1007     <C:time-range start="{start}" end="{end}"/>
1008   </C:comp-filter></C:comp-filter></C:filter>
1009 </C:calendar-query>"#
1010     )
1011 }
1012 
1013 #[allow(clippy::too_many_arguments)]
1014 fn fetch_events(
1015     client: &reqwest::blocking::Client,
1016     acc: &Account,
1017     cal: &reqwest::Url,
1018     start: &str,
1019     end: &str,
1020     window: (NaiveDate, NaiveDate),
1021     source: &str,
1022     seen: &mut BTreeSet<(String, Option<String>, String)>,
1023     events: &mut BTreeMap<String, RemoteEvent>,
1024 ) -> Result<(), String> {
1025     // Server-side expansion first: recurrences come back as concrete
1026     // instances in UTC, so no RRULE or VTIMEZONE handling is needed here.
1027     let (xml, expanded) =
1028         match dav_request(client, acc, "REPORT", cal, "1", &calendar_query(start, end, true)) {
1029             Ok(xml) => (xml, true),
1030             Err(e) => {
1031                 log::warn!("expand REPORT failed ({e}); retrying without expansion");
1032                 (dav_request(client, acc, "REPORT", cal, "1", &calendar_query(start, end, false))?, false)
1033             }
1034         };
1035     let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
1036     let mut skipped_rrule = 0usize;
1037     for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
1038         let href = resp
1039             .children()
1040             .find(|c| c.tag_name().name() == "href")
1041             .and_then(|n| n.text())
1042             .unwrap_or_default();
1043         let etag = resp
1044             .descendants()
1045             .find(|n| n.tag_name().name() == "getetag")
1046             .and_then(|n| n.text())
1047             .unwrap_or_default()
1048             .to_string();
1049         let Some(ics) = resp
1050             .descendants()
1051             .find(|n| n.tag_name().name() == "calendar-data")
1052             .and_then(|n| n.text())
1053         else {
1054             continue;
1055         };
1056         let url = cal.join(href.trim()).map_err(|e| format!("bad href {href:?}: {e}"))?;
1057         let vevents = parse_ics_events(ics);
1058         // A resource is recurring if it says so, or if expansion produced
1059         // more than one dated VEVENT of it.
1060         let recurring = vevents.len() > 1
1061             || vevents.iter().any(|v| v.has_rrule || v.has_recurrence_id);
1062         if !expanded && recurring {
1063             skipped_rrule += 1;
1064             continue;
1065         }
1066         let Some(uid) = vevents.iter().map(|v| v.uid.clone()).find(|u| !u.is_empty()) else {
1067             continue;
1068         };
1069         let mut instances = Vec::new();
1070         for ev in &vevents {
1071             event_to_records(ev, window, source, recurring, seen, &mut instances);
1072         }
1073         events.insert(
1074             uid,
1075             RemoteEvent { url, etag, recurring, instances, lines: unfold(ics) },
1076         );
1077     }
1078     if skipped_rrule > 0 {
1079         log::warn!("{skipped_rrule} recurring event(s) skipped (server refused expansion)");
1080     }
1081     Ok(())
1082 }
1083 
1084 fn put_ics(
1085     client: &reqwest::blocking::Client,
1086     acc: &Account,
1087     url: &reqwest::Url,
1088     body: &str,
1089     etag: Option<&str>,
1090 ) -> Result<String, String> {
1091     let mut req = client
1092         .put(url.clone())
1093         .basic_auth(&acc.email, Some(&acc.password))
1094         .header("Content-Type", "text/calendar; charset=utf-8")
1095         .body(body.to_string());
1096     req = match etag {
1097         Some(e) if !e.is_empty() => req.header("If-Match", e),
1098         Some(_) => req,
1099         None => req.header("If-None-Match", "*"),
1100     };
1101     let resp = req.send().map_err(|e| format!("PUT {url}: {e}"))?;
1102     let status = resp.status();
1103     if !status.is_success() {
1104         return Err(format!("PUT {url}: HTTP {status}"));
1105     }
1106     let etag = resp
1107         .headers()
1108         .get("etag")
1109         .and_then(|v| v.to_str().ok())
1110         .unwrap_or_default()
1111         .to_string();
1112     if !etag.is_empty() {
1113         return Ok(etag);
1114     }
1115     Ok(fetch_etag(client, acc, url).unwrap_or_default())
1116 }
1117 
1118 fn fetch_etag(
1119     client: &reqwest::blocking::Client,
1120     acc: &Account,
1121     url: &reqwest::Url,
1122 ) -> Option<String> {
1123     let body = r#"<?xml version="1.0" encoding="utf-8"?>
1124 <propfind xmlns="DAV:"><prop><getetag/></prop></propfind>"#;
1125     let xml = dav_request(client, acc, "PROPFIND", url, "0", body).ok()?;
1126     let doc = roxmltree::Document::parse(&xml).ok()?;
1127     doc.descendants()
1128         .find(|n| n.tag_name().name() == "getetag")
1129         .and_then(|n| n.text())
1130         .map(|s| s.to_string())
1131 }
1132 
1133 fn delete_ics(
1134     client: &reqwest::blocking::Client,
1135     acc: &Account,
1136     url: &reqwest::Url,
1137     etag: &str,
1138 ) -> Result<(), String> {
1139     let mut req = client.delete(url.clone()).basic_auth(&acc.email, Some(&acc.password));
1140     if !etag.is_empty() {
1141         req = req.header("If-Match", etag);
1142     }
1143     let resp = req.send().map_err(|e| format!("DELETE {url}: {e}"))?;
1144     let status = resp.status();
1145     if status.is_success() || status == reqwest::StatusCode::NOT_FOUND {
1146         Ok(())
1147     } else {
1148         Err(format!("DELETE {url}: HTTP {status}"))
1149     }
1150 }
1151 
1152 // ── iCalendar parsing (the few fields this mirror needs) ──────────────────
1153 
1154 #[derive(Debug, Default)]
1155 struct VEvent {
1156     uid: String,
1157     summary: String,
1158     dtstart: Option<DtValue>,
1159     dtend: Option<DtValue>,
1160     cancelled: bool,
1161     has_rrule: bool,
1162     has_recurrence_id: bool,
1163 }
1164 
1165 #[derive(Debug, Clone)]
1166 enum DtValue {
1167     /// All-day (VALUE=DATE).
1168     Date(NaiveDate),
1169     /// Zulu-suffixed date-time (what `<C:expand>` yields).
1170     Utc(DateTime<Utc>),
1171     /// Floating or TZID-qualified local time.
1172     Zoned(NaiveDateTime, Option<String>),
1173 }
1174 
1175 /// RFC 5545 line unfolding: a CRLF (or LF) followed by a space or tab
1176 /// continues the previous line.
1177 fn unfold(ics: &str) -> Vec<String> {
1178     let mut lines: Vec<String> = Vec::new();
1179     for raw in ics.split('\n') {
1180         let raw = raw.strip_suffix('\r').unwrap_or(raw);
1181         if let Some(rest) = raw.strip_prefix(' ').or_else(|| raw.strip_prefix('\t')) {
1182             if let Some(last) = lines.last_mut() {
1183                 last.push_str(rest);
1184                 continue;
1185             }
1186         }
1187         lines.push(raw.to_string());
1188     }
1189     lines.retain(|l| !l.is_empty());
1190     lines
1191 }
1192 
1193 /// Split a content line at the first ':' outside double quotes.
1194 fn split_content_line(line: &str) -> Option<(&str, &str)> {
1195     let mut in_quotes = false;
1196     for (i, c) in line.char_indices() {
1197         match c {
1198             '"' => in_quotes = !in_quotes,
1199             ':' if !in_quotes => return Some((&line[..i], &line[i + 1..])),
1200             _ => {}
1201         }
1202     }
1203     None
1204 }
1205 
1206 fn prop_name(line: &str) -> String {
1207     split_content_line(line)
1208         .map(|(h, _)| h.split(';').next().unwrap_or("").to_ascii_uppercase())
1209         .unwrap_or_default()
1210 }
1211 
1212 fn unescape_text(v: &str) -> String {
1213     let mut out = String::with_capacity(v.len());
1214     let mut chars = v.chars();
1215     while let Some(c) = chars.next() {
1216         if c != '\\' {
1217             out.push(c);
1218             continue;
1219         }
1220         match chars.next() {
1221             Some('n') | Some('N') => out.push(' '),
1222             Some(other) => out.push(other),
1223             None => {}
1224         }
1225     }
1226     out
1227 }
1228 
1229 fn escape_text(v: &str) -> String {
1230     let mut out = String::with_capacity(v.len());
1231     for c in v.chars() {
1232         match c {
1233             '\\' => out.push_str("\\\\"),
1234             ',' => out.push_str("\\,"),
1235             ';' => out.push_str("\\;"),
1236             '\n' => out.push_str("\\n"),
1237             _ => out.push(c),
1238         }
1239     }
1240     out
1241 }
1242 
1243 fn parse_dt(name_and_params: &str, value: &str) -> Option<DtValue> {
1244     let mut tzid = None;
1245     let mut is_date = false;
1246     for param in name_and_params.split(';').skip(1) {
1247         let (k, v) = param.split_once('=').unwrap_or((param, ""));
1248         match k.to_ascii_uppercase().as_str() {
1249             "TZID" => tzid = Some(v.trim_matches('"').to_string()),
1250             "VALUE" if v.eq_ignore_ascii_case("DATE") => is_date = true,
1251             _ => {}
1252         }
1253     }
1254     let value = value.trim();
1255     if is_date || value.len() == 8 {
1256         return NaiveDate::parse_from_str(value, "%Y%m%d").ok().map(DtValue::Date);
1257     }
1258     if let Some(stripped) = value.strip_suffix('Z') {
1259         let ndt = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
1260         return Some(DtValue::Utc(Utc.from_utc_datetime(&ndt)));
1261     }
1262     let ndt = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S").ok()?;
1263     Some(DtValue::Zoned(ndt, tzid))
1264 }
1265 
1266 fn parse_ics_events(ics: &str) -> Vec<VEvent> {
1267     let mut events = Vec::new();
1268     let mut current: Option<VEvent> = None;
1269     for line in unfold(ics) {
1270         let Some((head, value)) = split_content_line(&line) else { continue };
1271         let name = head.split(';').next().unwrap_or("").to_ascii_uppercase();
1272         match name.as_str() {
1273             "BEGIN" if value.eq_ignore_ascii_case("VEVENT") => {
1274                 current = Some(VEvent::default());
1275             }
1276             "END" if value.eq_ignore_ascii_case("VEVENT") => {
1277                 if let Some(ev) = current.take() {
1278                     events.push(ev);
1279                 }
1280             }
1281             _ => {
1282                 let Some(ev) = current.as_mut() else { continue };
1283                 match name.as_str() {
1284                     "UID" => ev.uid = value.trim().to_string(),
1285                     "SUMMARY" => ev.summary = unescape_text(value.trim()),
1286                     "DTSTART" => ev.dtstart = parse_dt(head, value),
1287                     "DTEND" => ev.dtend = parse_dt(head, value),
1288                     "RRULE" | "RDATE" => ev.has_rrule = true,
1289                     "RECURRENCE-ID" => ev.has_recurrence_id = true,
1290                     "STATUS" => ev.cancelled = value.trim().eq_ignore_ascii_case("CANCELLED"),
1291                     _ => {}
1292                 }
1293             }
1294         }
1295     }
1296     events
1297 }
1298 
1299 /// The DTSTART/DTEND pair for a record: UTC for timed events, DATE for
1300 /// all-day ones (DTEND exclusive, one day).
1301 fn ics_span(r: &EventRecord) -> Result<(String, String), String> {
1302     let (date, time) = parse_record_time(r)?;
1303     if time.is_some() {
1304         let (s, e) = record_span(r)?;
1305         let fmt = |t: DateTime<Local>| t.with_timezone(&Utc).format("%Y%m%dT%H%M%SZ").to_string();
1306         Ok((format!("DTSTART:{}", fmt(s)), format!("DTEND:{}", fmt(e))))
1307     } else {
1308         let next = date.checked_add_days(Days::new(1)).ok_or("date overflow")?;
1309         Ok((
1310             format!("DTSTART;VALUE=DATE:{}", date.format("%Y%m%d")),
1311             format!("DTEND;VALUE=DATE:{}", next.format("%Y%m%d")),
1312         ))
1313     }
1314 }
1315 
1316 fn new_vevent(uid: &str, r: &EventRecord) -> Result<String, String> {
1317     let now = Utc::now().format("%Y%m%dT%H%M%SZ");
1318     let (dtstart, dtend) = ics_span(r)?;
1319     Ok(format!(
1320         "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//cce//cce-calendar-sync//EN\r\n\
1321          BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:{now}\r\nCREATED:{now}\r\n{dtstart}\r\n{dtend}\r\n\
1322          SUMMARY:{}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
1323         escape_text(&r.title)
1324     ))
1325 }
1326 
1327 /// Rewrite only SUMMARY, DTSTART and DTEND (and drop a DURATION, which
1328 /// DTEND supersedes) inside the first VEVENT, leaving every other property
1329 /// — VALARM, X-APPLE-*, DESCRIPTION — exactly as the server sent it.
1330 fn patch_vevent(lines: &[String], r: &EventRecord) -> Result<String, String> {
1331     let (dtstart, dtend) = ics_span(r)?;
1332     let mut out: Vec<String> = Vec::with_capacity(lines.len() + 3);
1333     let mut in_event = false;
1334     let mut patched = false;
1335     for line in lines {
1336         let name = prop_name(line);
1337         let value = split_content_line(line).map(|(_, v)| v).unwrap_or_default();
1338         if name == "BEGIN" && value.eq_ignore_ascii_case("VEVENT") && !patched {
1339             in_event = true;
1340             out.push(line.clone());
1341             continue;
1342         }
1343         if in_event && name == "END" && value.eq_ignore_ascii_case("VEVENT") {
1344             out.push(format!("SUMMARY:{}", escape_text(&r.title)));
1345             out.push(dtstart.clone());
1346             out.push(dtend.clone());
1347             in_event = false;
1348             patched = true;
1349             out.push(line.clone());
1350             continue;
1351         }
1352         if in_event && matches!(name.as_str(), "SUMMARY" | "DTSTART" | "DTEND" | "DURATION") {
1353             continue;
1354         }
1355         out.push(line.clone());
1356     }
1357     if !patched {
1358         return Err("no VEVENT to patch".into());
1359     }
1360     let mut s = out.join("\r\n");
1361     s.push_str("\r\n");
1362     Ok(s)
1363 }
1364 
1365 /// Random-enough UID from the kernel, no uuid dependency.
1366 fn new_uid() -> String {
1367     let mut bytes = [0u8; 16];
1368     if std::fs::File::open("/dev/urandom")
1369         .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
1370         .is_err()
1371     {
1372         return format!("CCE-{}", Utc::now().format("%Y%m%dT%H%M%S%fZ"));
1373     }
1374     let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
1375     format!("CCE-{}-{}-{}", &hex[..8], &hex[8..16], &hex[16..])
1376 }
1377 
1378 // ── VEvent → records ──────────────────────────────────────────────────────
1379 
1380 fn to_local(dt: &DtValue) -> (NaiveDate, Option<(u32, u32)>) {
1381     use chrono::Timelike;
1382     match dt {
1383         DtValue::Date(d) => (*d, None),
1384         DtValue::Utc(dt) => {
1385             let local = dt.with_timezone(&Local);
1386             (local.date_naive(), Some((local.time().hour(), local.time().minute())))
1387         }
1388         DtValue::Zoned(ndt, tzid) => {
1389             let converted = tzid
1390                 .as_deref()
1391                 .and_then(|id| chrono_tz::Tz::from_str(id).ok())
1392                 .and_then(|tz| tz.from_local_datetime(ndt).earliest())
1393                 .map(|dt| dt.with_timezone(&Local).naive_local());
1394             if converted.is_none() && tzid.is_some() {
1395                 log::warn!("unknown TZID {:?}; treating as local time", tzid.as_deref().unwrap());
1396             }
1397             let ndt = converted.unwrap_or(*ndt);
1398             (ndt.date(), Some((ndt.time().hour(), ndt.time().minute())))
1399         }
1400     }
1401 }
1402 
1403 fn event_to_records(
1404     ev: &VEvent,
1405     window: (NaiveDate, NaiveDate),
1406     source: &str,
1407     recurring: bool,
1408     seen: &mut BTreeSet<(String, Option<String>, String)>,
1409     out: &mut Vec<EventRecord>,
1410 ) {
1411     if ev.cancelled {
1412         return;
1413     }
1414     let Some(dtstart) = &ev.dtstart else { return };
1415     let title = if ev.summary.is_empty() { "(untitled)".to_string() } else { ev.summary.clone() };
1416     let uid = (!ev.uid.is_empty()).then(|| ev.uid.clone());
1417 
1418     let mut push = |date: NaiveDate, time: Option<(u32, u32)>| {
1419         if date < window.0 || date > window.1 {
1420             return;
1421         }
1422         let date_s = date.to_string();
1423         let time_s = time.map(|(h, m)| format!("{h:02}:{m:02}"));
1424         // Expanded instances of one event share a UID; the (date, time,
1425         // title) key is what makes each day's mirror record unique.
1426         if seen.insert((date_s.clone(), time_s.clone(), title.clone())) {
1427             out.push(EventRecord {
1428                 date: date_s,
1429                 time: time_s,
1430                 title: title.clone(),
1431                 uid: uid.clone(),
1432                 source: Some(source.to_string()),
1433                 recurring,
1434             });
1435         }
1436     };
1437 
1438     match to_local(dtstart) {
1439         (date, Some(time)) => push(date, Some(time)),
1440         (start, None) => {
1441             // All-day: DTEND is exclusive per RFC 5545; a missing one means
1442             // a single day. One untimed record per covered day. A multi-day
1443             // all-day event is several records of one uid, which the file
1444             // cannot edit as one thing — so it is mirrored read-only too.
1445             let end = match ev.dtend.as_ref().map(to_local) {
1446                 Some((d, _)) if d > start => d,
1447                 _ => start.checked_add_days(Days::new(1)).unwrap_or(start),
1448             };
1449             let mut day = start;
1450             let mut span = 0;
1451             while day < end && span < MAX_ALLDAY_SPAN {
1452                 push(day, None);
1453                 let Some(next) = day.checked_add_days(Days::new(1)) else { break };
1454                 day = next;
1455                 span += 1;
1456             }
1457         }
1458     }
1459 }
1460 
1461 #[cfg(test)]
1462 mod tests {
1463     use super::*;
1464 
1465     fn record(date: &str, time: Option<&str>, title: &str) -> EventRecord {
1466         EventRecord {
1467             date: date.into(),
1468             time: time.map(String::from),
1469             title: title.into(),
1470             uid: None,
1471             source: None,
1472             recurring: false,
1473         }
1474     }
1475 
1476     #[test]
1477     fn unfolds_continuation_lines() {
1478         let lines = unfold("SUMMARY:split\r\n  over\r\nUID:x\n\tmore");
1479         assert_eq!(lines, vec!["SUMMARY:split over", "UID:xmore"]);
1480     }
1481 
1482     #[test]
1483     fn parses_expanded_utc_event() {
1484         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";
1485         let events = parse_ics_events(ics);
1486         assert_eq!(events.len(), 1);
1487         assert_eq!(events[0].uid, "abc");
1488         assert_eq!(events[0].summary, "Dentist, checkup");
1489         assert!(matches!(events[0].dtstart, Some(DtValue::Utc(_))));
1490         assert!(!events[0].has_rrule && !events[0].has_recurrence_id);
1491     }
1492 
1493     #[test]
1494     fn all_day_span_yields_one_record_per_day_dtend_exclusive() {
1495         let ev = VEvent {
1496             uid: "trip".into(),
1497             summary: "Trip".into(),
1498             dtstart: Some(DtValue::Date(NaiveDate::from_ymd_opt(2026, 9, 10).unwrap())),
1499             dtend: Some(DtValue::Date(NaiveDate::from_ymd_opt(2026, 9, 13).unwrap())),
1500             ..Default::default()
1501         };
1502         let window = (
1503             NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(),
1504             NaiveDate::from_ymd_opt(2026, 12, 1).unwrap(),
1505         );
1506         let (mut seen, mut out) = (BTreeSet::new(), Vec::new());
1507         event_to_records(&ev, window, "icloud:x", false, &mut seen, &mut out);
1508         assert_eq!(
1509             out.iter().map(|r| r.date.as_str()).collect::<Vec<_>>(),
1510             vec!["2026-09-10", "2026-09-11", "2026-09-12"]
1511         );
1512         assert!(out.iter().all(|r| r.time.is_none() && r.source.as_deref() == Some("icloud:x")));
1513     }
1514 
1515     #[test]
1516     fn cancelled_events_are_dropped() {
1517         let ics = "BEGIN:VEVENT\r\nUID:x\r\nSTATUS:CANCELLED\r\nDTSTART:20260901T140000Z\r\nEND:VEVENT\r\n";
1518         let ev = &parse_ics_events(ics)[0];
1519         let (mut seen, mut out) = (BTreeSet::new(), Vec::new());
1520         let window = (
1521             NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1522             NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
1523         );
1524         event_to_records(ev, window, "s", false, &mut seen, &mut out);
1525         assert!(out.is_empty());
1526     }
1527 
1528     #[test]
1529     fn new_vevent_all_day_and_timed() {
1530         let all_day = new_vevent("U1", &record("2026-09-10", None, "Trip, day")).unwrap();
1531         assert!(all_day.contains("DTSTART;VALUE=DATE:20260910"));
1532         assert!(all_day.contains("DTEND;VALUE=DATE:20260911"));
1533         assert!(all_day.contains("SUMMARY:Trip\\, day"));
1534         let timed = new_vevent("U2", &record("2026-09-10", Some("09:30"), "Call")).unwrap();
1535         // UTC form, an hour long; the exact hour depends on the local zone.
1536         assert!(timed.contains("DTSTART:20260910T") || timed.contains("DTSTART:20260911T"));
1537         assert!(timed.contains("Z\r\nDTEND:"));
1538     }
1539 
1540     #[test]
1541     fn patch_keeps_foreign_properties_and_replaces_the_span() {
1542         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";
1543         let patched = patch_vevent(&unfold(ics), &record("2026-10-02", None, "new")).unwrap();
1544         assert!(patched.contains("BEGIN:VALARM"));
1545         assert!(patched.contains("X-APPLE-TRAVEL:1"));
1546         assert!(patched.contains("SUMMARY:new"));
1547         assert!(patched.contains("DTSTART;VALUE=DATE:20261002"));
1548         assert!(!patched.contains("SUMMARY:old"));
1549         assert!(!patched.contains("DURATION"));
1550         assert!(!patched.contains("TZID"));
1551     }
1552 
1553     #[test]
1554     fn google_body_shapes() {
1555         let all_day = google_body(&record("2026-09-10", None, "x")).unwrap();
1556         assert_eq!(all_day["start"]["date"], "2026-09-10");
1557         assert_eq!(all_day["end"]["date"], "2026-09-11");
1558         let timed = google_body(&record("2026-09-10", Some("09:30"), "x")).unwrap();
1559         assert!(timed["start"]["dateTime"].as_str().unwrap().starts_with("2026-09-10T09:30:00"));
1560     }
1561 
1562     #[test]
1563     fn synced_event_matches_on_the_editable_triple() {
1564         let s = SyncedEvent {
1565             source: "icloud:a".into(),
1566             url: "u".into(),
1567             etag: "e".into(),
1568             date: "2026-09-10".into(),
1569             time: Some("09:30".into()),
1570             title: "x".into(),
1571         };
1572         assert!(s.matches(&record("2026-09-10", Some("09:30"), "x")));
1573         assert!(!s.matches(&record("2026-09-10", Some("10:30"), "x")));
1574         assert!(!s.matches(&record("2026-09-10", Some("09:30"), "y")));
1575     }
1576 }