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 }