Rust · 190537 bytes Raw Blame History
1 use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 env,
4 error::Error,
5 fmt, fs, io,
6 io::{BufRead, BufReader, Read, Write},
7 net::TcpListener,
8 path::{Path, PathBuf},
9 process::Command,
10 thread,
11 time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
12 };
13
14 use serde::{Deserialize, Serialize};
15 use serde_json::{Map, Value, json};
16 use sha2::{Digest, Sha256};
17 use time::{Month, Time, Weekday};
18
19 use crate::{
20 agenda::{
21 AgendaError, AgendaSource, CreateEventDraft, CreateEventTiming, DateRange, Event,
22 EventDateTime, EventWriteTarget, EventWriteTargetId, Holiday, InMemoryAgendaSource,
23 OccurrenceAnchor, OccurrenceMetadata, RecurrenceEnd, RecurrenceFrequency,
24 RecurrenceMonthlyRule, RecurrenceOrdinal, RecurrenceRule, RecurrenceYearlyRule, Reminder,
25 SourceMetadata,
26 },
27 calendar::CalendarDate,
28 };
29
30 const MICROSOFT_CACHE_VERSION: u8 = 1;
31 const GOOGLE_CACHE_VERSION: u8 = 1;
32 const GRAPH_BASE_URL: &str = "https://graph.microsoft.com/v1.0";
33 const LOGIN_BASE_URL: &str = "https://login.microsoftonline.com";
34 const MICROSOFT_SCOPES: &str = "offline_access User.Read Calendars.ReadWrite";
35 const KEYRING_SERVICE: &str = "rcal.microsoft";
36 const GOOGLE_CALENDAR_BASE_URL: &str = "https://www.googleapis.com/calendar/v3";
37 const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
38 const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
39 const GOOGLE_SCOPES: &str = concat!(
40 "https://www.googleapis.com/auth/calendar.calendarlist.readonly",
41 " ",
42 "https://www.googleapis.com/auth/calendar.events"
43 );
44 const GOOGLE_KEYRING_SERVICE: &str = "rcal.google";
45 pub const MICROSOFT_OFFICIAL_CLIENT_ID: &str = "9a49eaac-422b-4192-a65d-82dc8f43c11d";
46 pub const MICROSOFT_DEFAULT_TENANT: &str = "common";
47 pub const GOOGLE_OFFICIAL_CLIENT_ID: &str =
48 "1074776721941-d1paj4fnnn77bd3rrcliodhoveafbise.apps.googleusercontent.com";
49 pub const GOOGLE_OFFICIAL_CLIENT_SECRET: &str = "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";
50
51 #[derive(Debug, Clone, PartialEq, Eq)]
52 pub struct ProviderConfig {
53 pub create_target: ProviderCreateTarget,
54 pub microsoft: MicrosoftProviderConfig,
55 pub google: GoogleProviderConfig,
56 }
57
58 impl Default for ProviderConfig {
59 fn default() -> Self {
60 Self {
61 create_target: ProviderCreateTarget::Local,
62 microsoft: MicrosoftProviderConfig::default(),
63 google: GoogleProviderConfig::default(),
64 }
65 }
66 }
67
68 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
69 pub enum ProviderCreateTarget {
70 Local,
71 Microsoft,
72 Google,
73 }
74
75 #[derive(Debug, Clone, PartialEq, Eq)]
76 pub struct MicrosoftProviderConfig {
77 pub enabled: bool,
78 pub default_account: Option<String>,
79 pub default_calendar: Option<String>,
80 pub sync_past_days: i32,
81 pub sync_future_days: i32,
82 pub cache_file: PathBuf,
83 pub accounts: Vec<MicrosoftAccountConfig>,
84 }
85
86 impl Default for MicrosoftProviderConfig {
87 fn default() -> Self {
88 Self {
89 enabled: false,
90 default_account: None,
91 default_calendar: None,
92 sync_past_days: 30,
93 sync_future_days: 365,
94 cache_file: default_microsoft_cache_file(),
95 accounts: Vec::new(),
96 }
97 }
98 }
99
100 impl MicrosoftProviderConfig {
101 pub fn account(&self, id: &str) -> Option<&MicrosoftAccountConfig> {
102 self.accounts.iter().find(|account| account.id == id)
103 }
104
105 pub fn default_account(&self) -> Option<&MicrosoftAccountConfig> {
106 self.default_account
107 .as_deref()
108 .and_then(|id| self.account(id))
109 .or_else(|| self.accounts.first())
110 }
111
112 pub fn default_calendar(&self) -> Option<(&MicrosoftAccountConfig, &str)> {
113 let account = self.default_account()?;
114 let calendar = self
115 .default_calendar
116 .as_deref()
117 .or_else(|| account.calendars.first().map(String::as_str))?;
118 Some((account, calendar))
119 }
120
121 pub fn validate(&self) -> Result<(), ProviderError> {
122 if !self.enabled {
123 return Ok(());
124 }
125 if self.accounts.is_empty() {
126 return Err(ProviderError::Config(
127 "providers.microsoft.enabled requires at least one account".to_string(),
128 ));
129 }
130 let mut seen = HashMap::new();
131 for account in &self.accounts {
132 if account.id.trim().is_empty() {
133 return Err(ProviderError::Config(
134 "Microsoft account id may not be empty".to_string(),
135 ));
136 }
137 if seen.insert(account.id.clone(), ()).is_some() {
138 return Err(ProviderError::Config(format!(
139 "duplicate Microsoft account id '{}'",
140 account.id
141 )));
142 }
143 if account.client_id.trim().is_empty() {
144 return Err(ProviderError::Config(format!(
145 "Microsoft account '{}' requires client_id",
146 account.id
147 )));
148 }
149 if account.tenant.trim().is_empty() {
150 return Err(ProviderError::Config(format!(
151 "Microsoft account '{}' requires tenant",
152 account.id
153 )));
154 }
155 if account
156 .calendars
157 .iter()
158 .any(|calendar| calendar.trim().is_empty())
159 {
160 return Err(ProviderError::Config(format!(
161 "Microsoft account '{}' has an empty calendar id",
162 account.id
163 )));
164 }
165 }
166 if let Some(default_account) = &self.default_account
167 && self.account(default_account).is_none()
168 {
169 return Err(ProviderError::Config(format!(
170 "providers.microsoft.default_account '{}' is not configured",
171 default_account
172 )));
173 }
174 if let Some(default_calendar) = &self.default_calendar {
175 let Some(account) = self.default_account() else {
176 return Err(ProviderError::Config(
177 "providers.microsoft.default_calendar requires a default account".to_string(),
178 ));
179 };
180 if !account
181 .calendars
182 .iter()
183 .any(|calendar| calendar == default_calendar)
184 {
185 return Err(ProviderError::Config(format!(
186 "default calendar '{}' is not listed for account '{}'",
187 default_calendar, account.id
188 )));
189 }
190 }
191 Ok(())
192 }
193 }
194
195 #[derive(Debug, Clone, PartialEq, Eq)]
196 pub struct MicrosoftAccountConfig {
197 pub id: String,
198 pub client_id: String,
199 pub tenant: String,
200 pub redirect_port: u16,
201 pub calendars: Vec<String>,
202 }
203
204 impl MicrosoftAccountConfig {
205 pub fn new_official(id: impl Into<String>) -> Self {
206 Self {
207 id: id.into(),
208 client_id: MICROSOFT_OFFICIAL_CLIENT_ID.to_string(),
209 tenant: MICROSOFT_DEFAULT_TENANT.to_string(),
210 redirect_port: 8765,
211 calendars: Vec::new(),
212 }
213 }
214
215 pub fn token_url(&self) -> String {
216 format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/token", self.tenant)
217 }
218
219 pub fn device_code_url(&self) -> String {
220 format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/devicecode", self.tenant)
221 }
222
223 pub fn authorize_url(&self) -> String {
224 format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/authorize", self.tenant)
225 }
226
227 fn redirect_uri(&self) -> String {
228 format!("http://localhost:{}/callback", self.redirect_port)
229 }
230 }
231
232 #[derive(Debug, Clone, PartialEq, Eq)]
233 pub struct GoogleProviderConfig {
234 pub enabled: bool,
235 pub default_account: Option<String>,
236 pub default_calendar: Option<String>,
237 pub sync_past_days: i32,
238 pub sync_future_days: i32,
239 pub cache_file: PathBuf,
240 pub accounts: Vec<GoogleAccountConfig>,
241 }
242
243 impl Default for GoogleProviderConfig {
244 fn default() -> Self {
245 Self {
246 enabled: false,
247 default_account: None,
248 default_calendar: None,
249 sync_past_days: 30,
250 sync_future_days: 365,
251 cache_file: default_google_cache_file(),
252 accounts: Vec::new(),
253 }
254 }
255 }
256
257 impl GoogleProviderConfig {
258 pub fn account(&self, id: &str) -> Option<&GoogleAccountConfig> {
259 self.accounts.iter().find(|account| account.id == id)
260 }
261
262 pub fn default_account(&self) -> Option<&GoogleAccountConfig> {
263 self.default_account
264 .as_deref()
265 .and_then(|id| self.account(id))
266 .or_else(|| self.accounts.first())
267 }
268
269 pub fn default_calendar(&self) -> Option<(&GoogleAccountConfig, &str)> {
270 let account = self.default_account()?;
271 let calendar = self
272 .default_calendar
273 .as_deref()
274 .or_else(|| account.calendars.first().map(String::as_str))?;
275 Some((account, calendar))
276 }
277
278 pub fn validate(&self) -> Result<(), ProviderError> {
279 if !self.enabled {
280 return Ok(());
281 }
282 if self.accounts.is_empty() {
283 return Err(ProviderError::Config(
284 "providers.google.enabled requires at least one account".to_string(),
285 ));
286 }
287 let mut seen = HashMap::new();
288 for account in &self.accounts {
289 if account.id.trim().is_empty() {
290 return Err(ProviderError::Config(
291 "Google account id may not be empty".to_string(),
292 ));
293 }
294 if seen.insert(account.id.clone(), ()).is_some() {
295 return Err(ProviderError::Config(format!(
296 "duplicate Google account id '{}'",
297 account.id
298 )));
299 }
300 if account.client_id.trim().is_empty() {
301 return Err(ProviderError::Config(format!(
302 "Google account '{}' requires client_id",
303 account.id
304 )));
305 }
306 if account
307 .calendars
308 .iter()
309 .any(|calendar| calendar.trim().is_empty())
310 {
311 return Err(ProviderError::Config(format!(
312 "Google account '{}' has an empty calendar id",
313 account.id
314 )));
315 }
316 }
317 if let Some(default_account) = &self.default_account
318 && self.account(default_account).is_none()
319 {
320 return Err(ProviderError::Config(format!(
321 "providers.google.default_account '{}' is not configured",
322 default_account
323 )));
324 }
325 if let Some(default_calendar) = &self.default_calendar {
326 let Some(account) = self.default_account() else {
327 return Err(ProviderError::Config(
328 "providers.google.default_calendar requires a default account".to_string(),
329 ));
330 };
331 if !account
332 .calendars
333 .iter()
334 .any(|calendar| calendar == default_calendar)
335 {
336 return Err(ProviderError::Config(format!(
337 "default Google calendar '{}' is not listed for account '{}'",
338 default_calendar, account.id
339 )));
340 }
341 }
342 Ok(())
343 }
344 }
345
346 #[derive(Debug, Clone, PartialEq, Eq)]
347 pub struct GoogleAccountConfig {
348 pub id: String,
349 pub client_id: String,
350 pub client_secret: Option<String>,
351 pub redirect_port: u16,
352 pub calendars: Vec<String>,
353 }
354
355 impl GoogleAccountConfig {
356 pub fn new(
357 id: impl Into<String>,
358 client_id: impl Into<String>,
359 client_secret: Option<String>,
360 ) -> Self {
361 Self {
362 id: id.into(),
363 client_id: client_id.into(),
364 client_secret,
365 redirect_port: 8766,
366 calendars: Vec::new(),
367 }
368 }
369
370 pub fn new_official(id: impl Into<String>) -> Result<Self, ProviderError> {
371 let Some((client_id, client_secret)) = google_official_client_config() else {
372 return Err(ProviderError::Config(
373 "this rcal build does not include an official Google OAuth client yet; pass --client-id and --client-secret or finish the official Google client registration".to_string(),
374 ));
375 };
376 Ok(Self::new(id, client_id, client_secret))
377 }
378
379 fn redirect_uri(&self) -> String {
380 format!("http://127.0.0.1:{}/callback", self.redirect_port)
381 }
382 }
383
384 pub fn google_official_client_config() -> Option<(String, Option<String>)> {
385 let client_id = GOOGLE_OFFICIAL_CLIENT_ID.trim();
386 if client_id.is_empty() {
387 return None;
388 }
389 let client_secret = (!GOOGLE_OFFICIAL_CLIENT_SECRET.trim().is_empty())
390 .then(|| GOOGLE_OFFICIAL_CLIENT_SECRET.to_string());
391 Some((GOOGLE_OFFICIAL_CLIENT_ID.to_string(), client_secret))
392 }
393
394 pub fn default_microsoft_cache_file() -> PathBuf {
395 if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
396 return PathBuf::from(cache_home)
397 .join("rcal")
398 .join("microsoft-cache.json");
399 }
400 if let Some(home) = env::var_os("HOME") {
401 return PathBuf::from(home)
402 .join(".cache")
403 .join("rcal")
404 .join("microsoft-cache.json");
405 }
406 env::temp_dir().join("rcal").join("microsoft-cache.json")
407 }
408
409 pub fn default_google_cache_file() -> PathBuf {
410 if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
411 return PathBuf::from(cache_home)
412 .join("rcal")
413 .join("google-cache.json");
414 }
415
416 if let Some(home) = env::var_os("HOME") {
417 return PathBuf::from(home)
418 .join(".cache")
419 .join("rcal")
420 .join("google-cache.json");
421 }
422 env::temp_dir().join("rcal").join("google-cache.json")
423 }
424
425 #[derive(Debug, Clone, PartialEq, Eq)]
426 pub struct MicrosoftCalendarInfo {
427 pub id: String,
428 pub name: String,
429 pub can_edit: bool,
430 pub is_default: bool,
431 }
432
433 #[derive(Debug, Clone, PartialEq, Eq)]
434 pub struct GoogleCalendarInfo {
435 pub id: String,
436 pub name: String,
437 pub can_edit: bool,
438 pub is_default: bool,
439 }
440
441 #[derive(Debug, Default, Clone)]
442 pub struct GoogleAgendaSource {
443 cache: GoogleCacheFile,
444 }
445
446 impl GoogleAgendaSource {
447 pub fn load(path: &Path) -> Result<Self, ProviderError> {
448 Ok(Self {
449 cache: GoogleCacheFile::load(path)?,
450 })
451 }
452
453 pub fn empty() -> Self {
454 Self {
455 cache: GoogleCacheFile::empty(),
456 }
457 }
458
459 pub fn event_by_id(&self, id: &str) -> Option<Event> {
460 self.cache
461 .accounts
462 .iter()
463 .flat_map(|account| &account.calendars)
464 .flat_map(|calendar| &calendar.events)
465 .find(|event| event.id == id)
466 .and_then(GoogleCachedEvent::to_event)
467 }
468
469 pub fn event_count(&self) -> usize {
470 self.cache
471 .accounts
472 .iter()
473 .flat_map(|account| &account.calendars)
474 .map(|calendar| calendar.events.len())
475 .sum()
476 }
477 }
478
479 impl AgendaSource for GoogleAgendaSource {
480 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
481 let cached_events = self
482 .cache
483 .accounts
484 .iter()
485 .flat_map(|account| &account.calendars)
486 .flat_map(|calendar| &calendar.events)
487 .collect::<Vec<_>>();
488 let concrete_occurrence_series_ids = cached_events
489 .iter()
490 .filter_map(|event| event.series_master_app_id.clone())
491 .collect::<HashSet<_>>();
492 let events = cached_events
493 .into_iter()
494 .filter(|event| {
495 event.event_type.as_deref() != Some("recurringMaster")
496 || !concrete_occurrence_series_ids.contains(&event.id)
497 })
498 .filter_map(GoogleCachedEvent::to_event)
499 .collect::<Vec<_>>();
500 let mut events = InMemoryAgendaSource::with_events_and_holidays(events, Vec::new())
501 .events_intersecting(range);
502 events.sort_by(|left, right| left.id.cmp(&right.id));
503 events
504 }
505
506 fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
507 Vec::new()
508 }
509
510 fn editable_event_by_id(&self, id: &str) -> Option<Event> {
511 self.event_by_id(id)
512 }
513 }
514
515 #[derive(Debug, Default, Clone)]
516 pub struct MicrosoftAgendaSource {
517 cache: MicrosoftCacheFile,
518 }
519
520 impl MicrosoftAgendaSource {
521 pub fn load(path: &Path) -> Result<Self, ProviderError> {
522 Ok(Self {
523 cache: MicrosoftCacheFile::load(path)?,
524 })
525 }
526
527 pub fn empty() -> Self {
528 Self {
529 cache: MicrosoftCacheFile::empty(),
530 }
531 }
532
533 pub fn event_by_id(&self, id: &str) -> Option<Event> {
534 self.cache
535 .accounts
536 .iter()
537 .flat_map(|account| &account.calendars)
538 .flat_map(|calendar| &calendar.events)
539 .find(|event| event.id == id)
540 .and_then(MicrosoftCachedEvent::to_event)
541 }
542
543 pub fn metadata_for_event(&self, id: &str) -> Option<MicrosoftEventMetadata> {
544 self.cache
545 .accounts
546 .iter()
547 .flat_map(|account| &account.calendars)
548 .flat_map(|calendar| &calendar.events)
549 .find(|event| event.id == id)
550 .map(MicrosoftCachedEvent::metadata)
551 }
552
553 pub fn event_count(&self) -> usize {
554 self.cache
555 .accounts
556 .iter()
557 .flat_map(|account| &account.calendars)
558 .map(|calendar| calendar.events.len())
559 .sum()
560 }
561 }
562
563 impl AgendaSource for MicrosoftAgendaSource {
564 fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
565 let cached_events = self
566 .cache
567 .accounts
568 .iter()
569 .flat_map(|account| &account.calendars)
570 .flat_map(|calendar| &calendar.events)
571 .collect::<Vec<_>>();
572 let concrete_occurrence_series_ids = cached_events
573 .iter()
574 .filter_map(|event| event.series_master_app_id.clone())
575 .collect::<HashSet<_>>();
576 let events = cached_events
577 .into_iter()
578 .filter(|event| {
579 event.event_type.as_deref() != Some("seriesMaster")
580 || !concrete_occurrence_series_ids.contains(&event.id)
581 })
582 .filter_map(MicrosoftCachedEvent::to_event)
583 .collect::<Vec<_>>();
584 let mut events = InMemoryAgendaSource::with_events_and_holidays(events, Vec::new())
585 .events_intersecting(range);
586 events.sort_by(|left, right| left.id.cmp(&right.id));
587 events
588 }
589
590 fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
591 Vec::new()
592 }
593
594 fn editable_event_by_id(&self, id: &str) -> Option<Event> {
595 self.event_by_id(id)
596 }
597 }
598
599 #[derive(Debug)]
600 pub struct MicrosoftProviderRuntime {
601 config: MicrosoftProviderConfig,
602 cache: MicrosoftCacheFile,
603 }
604
605 impl MicrosoftProviderRuntime {
606 pub fn load(config: MicrosoftProviderConfig) -> Result<Self, ProviderError> {
607 config.validate()?;
608 let cache = MicrosoftCacheFile::load(&config.cache_file)?;
609 Ok(Self { config, cache })
610 }
611
612 pub fn agenda_source(&self) -> MicrosoftAgendaSource {
613 MicrosoftAgendaSource {
614 cache: self.cache.clone(),
615 }
616 }
617
618 pub fn write_targets(&self) -> Vec<EventWriteTarget> {
619 self.config
620 .accounts
621 .iter()
622 .flat_map(|account| {
623 account.calendars.iter().filter_map(|calendar_id| {
624 let record = self.cache.calendar_record(&account.id, calendar_id);
625 if record.as_ref().is_some_and(|calendar| !calendar.can_edit) {
626 return None;
627 }
628 let label = record
629 .as_ref()
630 .map(|calendar| calendar.name.as_str())
631 .filter(|name| !name.trim().is_empty())
632 .map(|name| format!("Microsoft {}: {name}", account.id))
633 .unwrap_or_else(|| {
634 format!(
635 "Microsoft {}: {}",
636 account.id,
637 short_calendar_label(calendar_id)
638 )
639 });
640 Some(EventWriteTarget::microsoft(
641 account.id.clone(),
642 calendar_id.clone(),
643 label,
644 ))
645 })
646 })
647 .collect()
648 }
649
650 pub fn default_write_target(&self) -> Option<EventWriteTargetId> {
651 let (account, calendar_id) = self.config.default_calendar()?;
652 Some(EventWriteTargetId::microsoft(
653 account.id.clone(),
654 calendar_id.to_string(),
655 ))
656 }
657
658 pub fn status(&self, token_store: &dyn MicrosoftTokenStore) -> MicrosoftProviderStatus {
659 let accounts = self
660 .config
661 .accounts
662 .iter()
663 .map(|account| MicrosoftAccountStatus {
664 id: account.id.clone(),
665 authenticated: token_store.load(&account.id).ok().flatten().is_some(),
666 calendars: account.calendars.clone(),
667 })
668 .collect::<Vec<_>>();
669 MicrosoftProviderStatus {
670 enabled: self.config.enabled,
671 cache_file: self.config.cache_file.clone(),
672 event_count: self.agenda_source().event_count(),
673 accounts,
674 }
675 }
676
677 pub fn sync(
678 &mut self,
679 account_filter: Option<&str>,
680 http: &dyn MicrosoftHttpClient,
681 token_store: &dyn MicrosoftTokenStore,
682 now: CalendarDate,
683 ) -> Result<MicrosoftSyncSummary, ProviderError> {
684 if !self.config.enabled {
685 return Err(ProviderError::Config(
686 "Microsoft provider is disabled".to_string(),
687 ));
688 }
689
690 let mut summary = MicrosoftSyncSummary::default();
691 let accounts = self
692 .config
693 .accounts
694 .iter()
695 .filter(|account| account_filter.map(|id| id == account.id).unwrap_or(true))
696 .cloned()
697 .collect::<Vec<_>>();
698
699 if accounts.is_empty() {
700 return Err(ProviderError::Config(format!(
701 "Microsoft account '{}' is not configured",
702 account_filter.unwrap_or("<none>")
703 )));
704 }
705
706 for account in accounts {
707 let token = access_token(&account, http, token_store)?;
708 let calendar_ids = account.calendars.clone();
709 for calendar_id in calendar_ids {
710 let calendar = fetch_calendar(http, &token, &calendar_id)?;
711 let start = now.add_days(-self.config.sync_past_days);
712 let end = now.add_days(self.config.sync_future_days);
713 let events = fetch_calendar_view(http, &token, &account.id, &calendar, start, end)?;
714 summary.events += events.len();
715 summary.calendars += 1;
716 self.cache
717 .replace_calendar(&account.id, calendar, events, current_epoch_seconds());
718 }
719 summary.accounts += 1;
720 }
721
722 self.cache.save(&self.config.cache_file)?;
723 Ok(summary)
724 }
725
726 pub fn create_event(
727 &mut self,
728 draft: CreateEventDraft,
729 http: &dyn MicrosoftHttpClient,
730 token_store: &dyn MicrosoftTokenStore,
731 ) -> Result<Event, ProviderError> {
732 let target = self.default_write_target().ok_or_else(|| {
733 ProviderError::Config("no Microsoft default calendar configured".to_string())
734 })?;
735 self.create_event_in_target(draft, &target, http, token_store)
736 }
737
738 pub fn create_event_in_target(
739 &mut self,
740 draft: CreateEventDraft,
741 target: &EventWriteTargetId,
742 http: &dyn MicrosoftHttpClient,
743 token_store: &dyn MicrosoftTokenStore,
744 ) -> Result<Event, ProviderError> {
745 let Some((account_id, calendar_id)) = target.microsoft_parts() else {
746 return Err(ProviderError::Config(
747 "Microsoft provider requires a Microsoft calendar target".to_string(),
748 ));
749 };
750 let account = self
751 .config
752 .account(account_id)
753 .ok_or_else(|| {
754 ProviderError::Config(format!("account '{account_id}' is not configured"))
755 })?
756 .clone();
757 if !account
758 .calendars
759 .iter()
760 .any(|calendar| calendar == calendar_id)
761 {
762 return Err(ProviderError::Config(format!(
763 "calendar '{calendar_id}' is not configured for account '{account_id}'"
764 )));
765 }
766 let token = access_token(&account, http, token_store)?;
767 let body = graph_event_payload(&draft, false)?;
768 let response = graph_request(
769 http,
770 "POST",
771 &format!(
772 "{GRAPH_BASE_URL}/me/calendars/{}/events",
773 percent_encode(calendar_id)
774 ),
775 &token,
776 Some(body.to_string()),
777 )?;
778 let value = parse_graph_success_json(response)?;
779 let calendar =
780 fetch_calendar(http, &token, calendar_id).unwrap_or(MicrosoftCalendarRecord {
781 id: calendar_id.to_string(),
782 name: calendar_id.to_string(),
783 can_edit: true,
784 is_default: false,
785 });
786 let cached = MicrosoftCachedEvent::from_graph(&account.id, &calendar, value)?;
787 self.cache
788 .upsert_event(&account.id, calendar, cached.clone());
789 self.cache.save(&self.config.cache_file)?;
790 cached.to_event().ok_or_else(|| {
791 ProviderError::Mapping("created Microsoft event could not be converted".to_string())
792 })
793 }
794
795 pub fn update_event(
796 &mut self,
797 id: &str,
798 draft: CreateEventDraft,
799 http: &dyn MicrosoftHttpClient,
800 token_store: &dyn MicrosoftTokenStore,
801 ) -> Result<Event, ProviderError> {
802 let metadata = self
803 .cache
804 .metadata_for_event(id)
805 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
806 let account = self
807 .config
808 .account(&metadata.account_id)
809 .ok_or_else(|| {
810 ProviderError::Config(format!(
811 "account '{}' is not configured",
812 metadata.account_id
813 ))
814 })?
815 .clone();
816 let token = access_token(&account, http, token_store)?;
817 let body = graph_event_payload(&draft, true)?;
818 let response = graph_request(
819 http,
820 "PATCH",
821 &format!(
822 "{GRAPH_BASE_URL}/me/events/{}",
823 percent_encode(&metadata.graph_id)
824 ),
825 &token,
826 Some(body.to_string()),
827 )?;
828 let value = parse_graph_success_json(response)?;
829 let calendar = self
830 .cache
831 .calendar_record(&metadata.account_id, &metadata.calendar_id)
832 .unwrap_or(MicrosoftCalendarRecord {
833 id: metadata.calendar_id.clone(),
834 name: metadata.calendar_id.clone(),
835 can_edit: true,
836 is_default: false,
837 });
838 let cached = MicrosoftCachedEvent::from_graph(&metadata.account_id, &calendar, value)?;
839 self.cache.remove_occurrences_for_series(&cached.id);
840 self.cache
841 .upsert_event(&metadata.account_id, calendar, cached.clone());
842 self.cache.save(&self.config.cache_file)?;
843 cached.to_event().ok_or_else(|| {
844 ProviderError::Mapping("updated Microsoft event could not be converted".to_string())
845 })
846 }
847
848 pub fn update_occurrence(
849 &mut self,
850 series_id: &str,
851 anchor: OccurrenceAnchor,
852 draft: CreateEventDraft,
853 http: &dyn MicrosoftHttpClient,
854 token_store: &dyn MicrosoftTokenStore,
855 ) -> Result<Event, ProviderError> {
856 let id = self
857 .cache
858 .event_id_for_anchor(series_id, anchor)
859 .ok_or_else(|| {
860 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
861 })?;
862 self.update_event(
863 &id,
864 draft.without_recurrence_for_provider(),
865 http,
866 token_store,
867 )
868 }
869
870 pub fn delete_event(
871 &mut self,
872 id: &str,
873 http: &dyn MicrosoftHttpClient,
874 token_store: &dyn MicrosoftTokenStore,
875 ) -> Result<Event, ProviderError> {
876 let metadata = self
877 .cache
878 .metadata_for_event(id)
879 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
880 let event = self
881 .cache
882 .event_by_id(id)
883 .and_then(|cached| cached.to_event())
884 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
885 let account = self
886 .config
887 .account(&metadata.account_id)
888 .ok_or_else(|| {
889 ProviderError::Config(format!(
890 "account '{}' is not configured",
891 metadata.account_id
892 ))
893 })?
894 .clone();
895 let token = access_token(&account, http, token_store)?;
896 let response = graph_request(
897 http,
898 "DELETE",
899 &format!(
900 "{GRAPH_BASE_URL}/me/events/{}",
901 percent_encode(&metadata.graph_id)
902 ),
903 &token,
904 None,
905 )?;
906 parse_graph_empty_success(response)?;
907 self.cache.remove_event(id);
908 self.cache.save(&self.config.cache_file)?;
909 Ok(event)
910 }
911
912 pub fn delete_occurrence(
913 &mut self,
914 series_id: &str,
915 anchor: OccurrenceAnchor,
916 http: &dyn MicrosoftHttpClient,
917 token_store: &dyn MicrosoftTokenStore,
918 ) -> Result<(), ProviderError> {
919 let id = self
920 .cache
921 .event_id_for_anchor(series_id, anchor)
922 .ok_or_else(|| {
923 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
924 })?;
925 self.delete_event(&id, http, token_store).map(|_| ())
926 }
927
928 pub fn duplicate_event(
929 &mut self,
930 id: &str,
931 http: &dyn MicrosoftHttpClient,
932 token_store: &dyn MicrosoftTokenStore,
933 ) -> Result<Event, ProviderError> {
934 let event = self
935 .cache
936 .event_by_id(id)
937 .and_then(|cached| cached.to_event())
938 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
939 self.create_event(
940 CreateEventDraft::from_event(&event).without_recurrence_for_provider(),
941 http,
942 token_store,
943 )
944 }
945
946 pub fn duplicate_occurrence(
947 &mut self,
948 series_id: &str,
949 anchor: OccurrenceAnchor,
950 http: &dyn MicrosoftHttpClient,
951 token_store: &dyn MicrosoftTokenStore,
952 ) -> Result<Event, ProviderError> {
953 let id = self
954 .cache
955 .event_id_for_anchor(series_id, anchor)
956 .ok_or_else(|| {
957 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
958 })?;
959 self.duplicate_event(&id, http, token_store)
960 }
961 }
962
963 #[derive(Debug)]
964 pub struct GoogleProviderRuntime {
965 config: GoogleProviderConfig,
966 cache: GoogleCacheFile,
967 }
968
969 impl GoogleProviderRuntime {
970 pub fn load(config: GoogleProviderConfig) -> Result<Self, ProviderError> {
971 config.validate()?;
972 let cache = GoogleCacheFile::load(&config.cache_file)?;
973 Ok(Self { config, cache })
974 }
975
976 pub fn agenda_source(&self) -> GoogleAgendaSource {
977 GoogleAgendaSource {
978 cache: self.cache.clone(),
979 }
980 }
981
982 pub fn write_targets(&self) -> Vec<EventWriteTarget> {
983 if !self.config.enabled {
984 return Vec::new();
985 }
986 self.config
987 .accounts
988 .iter()
989 .flat_map(|account| {
990 account.calendars.iter().map(|calendar_id| {
991 let label = self
992 .cache
993 .calendar_record(&account.id, calendar_id)
994 .map(|calendar| format!("Google {}: {}", account.id, calendar.name))
995 .unwrap_or_else(|| {
996 format!(
997 "Google {}: {}",
998 account.id,
999 short_calendar_label(calendar_id)
1000 )
1001 });
1002 EventWriteTarget::provider("google", &account.id, calendar_id, label)
1003 })
1004 })
1005 .collect()
1006 }
1007
1008 pub fn default_write_target(&self) -> Option<EventWriteTargetId> {
1009 let (account, calendar_id) = self.config.default_calendar()?;
1010 Some(EventWriteTargetId::provider(
1011 "google",
1012 account.id.clone(),
1013 calendar_id.to_string(),
1014 ))
1015 }
1016
1017 pub fn status(&self, token_store: &dyn GoogleTokenStore) -> GoogleProviderStatus {
1018 let accounts = self
1019 .config
1020 .accounts
1021 .iter()
1022 .map(|account| GoogleAccountStatus {
1023 id: account.id.clone(),
1024 authenticated: token_store.load(&account.id).ok().flatten().is_some(),
1025 calendars: account.calendars.clone(),
1026 })
1027 .collect();
1028 GoogleProviderStatus {
1029 enabled: self.config.enabled,
1030 cache_file: self.config.cache_file.clone(),
1031 event_count: self.agenda_source().event_count(),
1032 accounts,
1033 }
1034 }
1035
1036 pub fn sync(
1037 &mut self,
1038 account_id: Option<&str>,
1039 http: &dyn MicrosoftHttpClient,
1040 token_store: &dyn GoogleTokenStore,
1041 now: CalendarDate,
1042 ) -> Result<GoogleSyncSummary, ProviderError> {
1043 if !self.config.enabled {
1044 return Err(ProviderError::Config(
1045 "Google provider is disabled".to_string(),
1046 ));
1047 }
1048 let mut summary = GoogleSyncSummary::default();
1049 let accounts = match account_id {
1050 Some(account_id) => {
1051 vec![self.config.account(account_id).cloned().ok_or_else(|| {
1052 ProviderError::Config(format!(
1053 "Google account '{account_id}' is not configured"
1054 ))
1055 })?]
1056 }
1057 None => self.config.accounts.clone(),
1058 };
1059 for account in accounts {
1060 let token = google_access_token(&account, http, token_store)?;
1061 let calendar_ids = account.calendars.clone();
1062 for calendar_id in calendar_ids {
1063 let calendar = fetch_google_calendar(http, &token, &calendar_id)?;
1064 let start = now.add_days(-self.config.sync_past_days);
1065 let end = now.add_days(self.config.sync_future_days);
1066 let events = fetch_google_events(http, &token, &account.id, &calendar, start, end)?;
1067 summary.events += events.len();
1068 summary.calendars += 1;
1069 self.cache
1070 .replace_calendar(&account.id, calendar, events, current_epoch_seconds());
1071 }
1072 summary.accounts += 1;
1073 }
1074
1075 self.cache.save(&self.config.cache_file)?;
1076 Ok(summary)
1077 }
1078
1079 pub fn create_event(
1080 &mut self,
1081 draft: CreateEventDraft,
1082 http: &dyn MicrosoftHttpClient,
1083 token_store: &dyn GoogleTokenStore,
1084 ) -> Result<Event, ProviderError> {
1085 let target = self.default_write_target().ok_or_else(|| {
1086 ProviderError::Config("no Google default calendar configured".to_string())
1087 })?;
1088 self.create_event_in_target(draft, &target, http, token_store)
1089 }
1090
1091 pub fn create_event_in_target(
1092 &mut self,
1093 draft: CreateEventDraft,
1094 target: &EventWriteTargetId,
1095 http: &dyn MicrosoftHttpClient,
1096 token_store: &dyn GoogleTokenStore,
1097 ) -> Result<Event, ProviderError> {
1098 let Some((_, account_id, calendar_id)) = target.provider_parts() else {
1099 return Err(ProviderError::Config(
1100 "Google provider requires a Google calendar target".to_string(),
1101 ));
1102 };
1103 if !target.is_provider("google") {
1104 return Err(ProviderError::Config(
1105 "Google provider requires a Google calendar target".to_string(),
1106 ));
1107 }
1108 let account = self
1109 .config
1110 .account(account_id)
1111 .ok_or_else(|| {
1112 ProviderError::Config(format!("Google account '{account_id}' is not configured"))
1113 })?
1114 .clone();
1115 if !account
1116 .calendars
1117 .iter()
1118 .any(|calendar| calendar == calendar_id)
1119 {
1120 return Err(ProviderError::Config(format!(
1121 "Google calendar '{calendar_id}' is not configured for account '{account_id}'"
1122 )));
1123 }
1124 let token = google_access_token(&account, http, token_store)?;
1125 let body = google_event_payload(&draft, false)?;
1126 let response = google_request(
1127 http,
1128 "POST",
1129 &format!(
1130 "{GOOGLE_CALENDAR_BASE_URL}/calendars/{}/events",
1131 percent_encode(calendar_id)
1132 ),
1133 &token,
1134 Some(body.to_string()),
1135 )?;
1136 let value = parse_google_success_json(response)?;
1137 let calendar =
1138 fetch_google_calendar(http, &token, calendar_id).unwrap_or(GoogleCalendarRecord {
1139 id: calendar_id.to_string(),
1140 name: calendar_id.to_string(),
1141 can_edit: true,
1142 is_default: false,
1143 });
1144 let cached = GoogleCachedEvent::from_google(&account.id, &calendar, value)?;
1145 self.cache
1146 .upsert_event(&account.id, calendar, cached.clone());
1147 self.cache.save(&self.config.cache_file)?;
1148 cached.to_event().ok_or_else(|| {
1149 ProviderError::Mapping("created Google event could not be converted".to_string())
1150 })
1151 }
1152
1153 pub fn update_event(
1154 &mut self,
1155 id: &str,
1156 draft: CreateEventDraft,
1157 http: &dyn MicrosoftHttpClient,
1158 token_store: &dyn GoogleTokenStore,
1159 ) -> Result<Event, ProviderError> {
1160 let metadata = self
1161 .cache
1162 .metadata_for_event(id)
1163 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
1164 let account = self
1165 .config
1166 .account(&metadata.account_id)
1167 .ok_or_else(|| {
1168 ProviderError::Config(format!(
1169 "Google account '{}' is not configured",
1170 metadata.account_id
1171 ))
1172 })?
1173 .clone();
1174 let token = google_access_token(&account, http, token_store)?;
1175 let body = google_event_payload(&draft, true)?;
1176 let response = google_request(
1177 http,
1178 "PATCH",
1179 &format!(
1180 "{GOOGLE_CALENDAR_BASE_URL}/calendars/{}/events/{}",
1181 percent_encode(&metadata.calendar_id),
1182 percent_encode(&metadata.google_id)
1183 ),
1184 &token,
1185 Some(body.to_string()),
1186 )?;
1187 let value = parse_google_success_json(response)?;
1188 let calendar = self
1189 .cache
1190 .calendar_record(&metadata.account_id, &metadata.calendar_id)
1191 .unwrap_or(GoogleCalendarRecord {
1192 id: metadata.calendar_id.clone(),
1193 name: metadata.calendar_id.clone(),
1194 can_edit: true,
1195 is_default: false,
1196 });
1197 let cached = GoogleCachedEvent::from_google(&metadata.account_id, &calendar, value)?;
1198 self.cache.remove_occurrences_for_series(&cached.id);
1199 self.cache
1200 .upsert_event(&metadata.account_id, calendar, cached.clone());
1201 self.cache.save(&self.config.cache_file)?;
1202 cached.to_event().ok_or_else(|| {
1203 ProviderError::Mapping("updated Google event could not be converted".to_string())
1204 })
1205 }
1206
1207 pub fn update_occurrence(
1208 &mut self,
1209 series_id: &str,
1210 anchor: OccurrenceAnchor,
1211 draft: CreateEventDraft,
1212 http: &dyn MicrosoftHttpClient,
1213 token_store: &dyn GoogleTokenStore,
1214 ) -> Result<Event, ProviderError> {
1215 let id = self
1216 .cache
1217 .event_id_for_anchor(series_id, anchor)
1218 .ok_or_else(|| {
1219 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
1220 })?;
1221 self.update_event(
1222 &id,
1223 draft.without_recurrence_for_provider(),
1224 http,
1225 token_store,
1226 )
1227 }
1228
1229 pub fn delete_event(
1230 &mut self,
1231 id: &str,
1232 http: &dyn MicrosoftHttpClient,
1233 token_store: &dyn GoogleTokenStore,
1234 ) -> Result<Event, ProviderError> {
1235 let metadata = self
1236 .cache
1237 .metadata_for_event(id)
1238 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
1239 let event = self
1240 .cache
1241 .event_by_id(id)
1242 .and_then(|cached| cached.to_event())
1243 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
1244 let account = self
1245 .config
1246 .account(&metadata.account_id)
1247 .ok_or_else(|| {
1248 ProviderError::Config(format!(
1249 "Google account '{}' is not configured",
1250 metadata.account_id
1251 ))
1252 })?
1253 .clone();
1254 let token = google_access_token(&account, http, token_store)?;
1255 let response = google_request(
1256 http,
1257 "DELETE",
1258 &format!(
1259 "{GOOGLE_CALENDAR_BASE_URL}/calendars/{}/events/{}",
1260 percent_encode(&metadata.calendar_id),
1261 percent_encode(&metadata.google_id)
1262 ),
1263 &token,
1264 None,
1265 )?;
1266 parse_google_empty_success(response)?;
1267 self.cache.remove_event(id);
1268 self.cache.save(&self.config.cache_file)?;
1269 Ok(event)
1270 }
1271
1272 pub fn delete_occurrence(
1273 &mut self,
1274 series_id: &str,
1275 anchor: OccurrenceAnchor,
1276 http: &dyn MicrosoftHttpClient,
1277 token_store: &dyn GoogleTokenStore,
1278 ) -> Result<(), ProviderError> {
1279 let id = self
1280 .cache
1281 .event_id_for_anchor(series_id, anchor)
1282 .ok_or_else(|| {
1283 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
1284 })?;
1285 self.delete_event(&id, http, token_store).map(|_| ())
1286 }
1287
1288 pub fn duplicate_event(
1289 &mut self,
1290 id: &str,
1291 http: &dyn MicrosoftHttpClient,
1292 token_store: &dyn GoogleTokenStore,
1293 ) -> Result<Event, ProviderError> {
1294 let event = self
1295 .cache
1296 .event_by_id(id)
1297 .and_then(|cached| cached.to_event())
1298 .ok_or_else(|| ProviderError::NotFound(id.to_string()))?;
1299 self.create_event(
1300 CreateEventDraft::from_event(&event).without_recurrence_for_provider(),
1301 http,
1302 token_store,
1303 )
1304 }
1305
1306 pub fn duplicate_occurrence(
1307 &mut self,
1308 series_id: &str,
1309 anchor: OccurrenceAnchor,
1310 http: &dyn MicrosoftHttpClient,
1311 token_store: &dyn GoogleTokenStore,
1312 ) -> Result<Event, ProviderError> {
1313 let id = self
1314 .cache
1315 .event_id_for_anchor(series_id, anchor)
1316 .ok_or_else(|| {
1317 ProviderError::NotFound(format!("{series_id}:{}", anchor_label(anchor)))
1318 })?;
1319 self.duplicate_event(&id, http, token_store)
1320 }
1321 }
1322
1323 trait ProviderDraftExt {
1324 fn without_recurrence_for_provider(self) -> Self;
1325 }
1326
1327 impl ProviderDraftExt for CreateEventDraft {
1328 fn without_recurrence_for_provider(mut self) -> Self {
1329 self.recurrence = None;
1330 self
1331 }
1332 }
1333
1334 #[derive(Debug, Clone, PartialEq, Eq)]
1335 pub struct MicrosoftProviderStatus {
1336 pub enabled: bool,
1337 pub cache_file: PathBuf,
1338 pub event_count: usize,
1339 pub accounts: Vec<MicrosoftAccountStatus>,
1340 }
1341
1342 #[derive(Debug, Clone, PartialEq, Eq)]
1343 pub struct MicrosoftAccountStatus {
1344 pub id: String,
1345 pub authenticated: bool,
1346 pub calendars: Vec<String>,
1347 }
1348
1349 #[derive(Debug, Default, Clone, PartialEq, Eq)]
1350 pub struct MicrosoftSyncSummary {
1351 pub accounts: usize,
1352 pub calendars: usize,
1353 pub events: usize,
1354 }
1355
1356 #[derive(Debug, Clone, PartialEq, Eq)]
1357 pub struct MicrosoftEventMetadata {
1358 pub account_id: String,
1359 pub calendar_id: String,
1360 pub graph_id: String,
1361 pub series_master_id: Option<String>,
1362 pub occurrence_anchor: Option<OccurrenceAnchor>,
1363 }
1364
1365 #[derive(Debug, Clone, PartialEq, Eq)]
1366 pub struct GoogleProviderStatus {
1367 pub enabled: bool,
1368 pub cache_file: PathBuf,
1369 pub event_count: usize,
1370 pub accounts: Vec<GoogleAccountStatus>,
1371 }
1372
1373 #[derive(Debug, Clone, PartialEq, Eq)]
1374 pub struct GoogleAccountStatus {
1375 pub id: String,
1376 pub authenticated: bool,
1377 pub calendars: Vec<String>,
1378 }
1379
1380 #[derive(Debug, Default, Clone, PartialEq, Eq)]
1381 pub struct GoogleSyncSummary {
1382 pub accounts: usize,
1383 pub calendars: usize,
1384 pub events: usize,
1385 }
1386
1387 #[derive(Debug, Clone, PartialEq, Eq)]
1388 pub struct GoogleEventMetadata {
1389 pub account_id: String,
1390 pub calendar_id: String,
1391 pub google_id: String,
1392 pub recurring_event_id: Option<String>,
1393 pub occurrence_anchor: Option<OccurrenceAnchor>,
1394 }
1395
1396 #[derive(Debug, Default, Clone, Serialize, Deserialize)]
1397 struct GoogleCacheFile {
1398 version: u8,
1399 #[serde(default)]
1400 accounts: Vec<GoogleCacheAccount>,
1401 }
1402
1403 impl GoogleCacheFile {
1404 fn empty() -> Self {
1405 Self {
1406 version: GOOGLE_CACHE_VERSION,
1407 accounts: Vec::new(),
1408 }
1409 }
1410
1411 fn load(path: &Path) -> Result<Self, ProviderError> {
1412 let body = match fs::read_to_string(path) {
1413 Ok(body) => body,
1414 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
1415 Err(err) => {
1416 return Err(ProviderError::CacheRead {
1417 path: path.to_path_buf(),
1418 reason: err.to_string(),
1419 });
1420 }
1421 };
1422 let file =
1423 serde_json::from_str::<Self>(&body).map_err(|err| ProviderError::CacheParse {
1424 path: path.to_path_buf(),
1425 reason: err.to_string(),
1426 })?;
1427 if file.version != GOOGLE_CACHE_VERSION {
1428 return Err(ProviderError::CacheParse {
1429 path: path.to_path_buf(),
1430 reason: format!("unsupported Google cache version {}", file.version),
1431 });
1432 }
1433 Ok(file)
1434 }
1435
1436 fn save(&self, path: &Path) -> Result<(), ProviderError> {
1437 if let Some(parent) = path.parent() {
1438 fs::create_dir_all(parent).map_err(|err| ProviderError::CacheWrite {
1439 path: parent.to_path_buf(),
1440 reason: err.to_string(),
1441 })?;
1442 }
1443 let body = serde_json::to_string_pretty(self).map_err(|err| ProviderError::CacheWrite {
1444 path: path.to_path_buf(),
1445 reason: err.to_string(),
1446 })?;
1447 let temp_path = path.with_extension("json.tmp");
1448 fs::write(&temp_path, body).map_err(|err| ProviderError::CacheWrite {
1449 path: temp_path.clone(),
1450 reason: err.to_string(),
1451 })?;
1452 fs::rename(&temp_path, path).map_err(|err| ProviderError::CacheWrite {
1453 path: path.to_path_buf(),
1454 reason: err.to_string(),
1455 })
1456 }
1457
1458 fn replace_calendar(
1459 &mut self,
1460 account_id: &str,
1461 calendar: GoogleCalendarRecord,
1462 events: Vec<GoogleCachedEvent>,
1463 synced_at_epoch_seconds: u64,
1464 ) {
1465 let account = self.account_mut(account_id);
1466 if let Some(existing) = account
1467 .calendars
1468 .iter_mut()
1469 .find(|existing| existing.id == calendar.id)
1470 {
1471 existing.name = calendar.name;
1472 existing.can_edit = calendar.can_edit;
1473 existing.is_default = calendar.is_default;
1474 existing.last_synced_at_epoch_seconds = Some(synced_at_epoch_seconds);
1475 existing.events = events;
1476 } else {
1477 account.calendars.push(GoogleCacheCalendar {
1478 id: calendar.id,
1479 name: calendar.name,
1480 can_edit: calendar.can_edit,
1481 is_default: calendar.is_default,
1482 sync_token: None,
1483 last_synced_at_epoch_seconds: Some(synced_at_epoch_seconds),
1484 events,
1485 });
1486 }
1487 account
1488 .calendars
1489 .sort_by(|left, right| left.id.cmp(&right.id));
1490 }
1491
1492 fn upsert_event(
1493 &mut self,
1494 account_id: &str,
1495 calendar: GoogleCalendarRecord,
1496 event: GoogleCachedEvent,
1497 ) {
1498 let account = self.account_mut(account_id);
1499 let calendar_record = if let Some(existing) = account
1500 .calendars
1501 .iter_mut()
1502 .find(|existing| existing.id == calendar.id)
1503 {
1504 existing
1505 } else {
1506 account.calendars.push(GoogleCacheCalendar {
1507 id: calendar.id.clone(),
1508 name: calendar.name.clone(),
1509 can_edit: calendar.can_edit,
1510 is_default: calendar.is_default,
1511 sync_token: None,
1512 last_synced_at_epoch_seconds: None,
1513 events: Vec::new(),
1514 });
1515 account.calendars.last_mut().expect("calendar was pushed")
1516 };
1517 if let Some(existing) = calendar_record
1518 .events
1519 .iter_mut()
1520 .find(|existing| existing.id == event.id)
1521 {
1522 *existing = event;
1523 } else {
1524 calendar_record.events.push(event);
1525 }
1526 calendar_record
1527 .events
1528 .sort_by(|left, right| left.id.cmp(&right.id));
1529 }
1530
1531 fn remove_event(&mut self, id: &str) {
1532 for calendar in self
1533 .accounts
1534 .iter_mut()
1535 .flat_map(|account| &mut account.calendars)
1536 {
1537 calendar.events.retain(|event| {
1538 event.id != id && event.series_master_app_id.as_deref() != Some(id)
1539 });
1540 }
1541 }
1542
1543 fn remove_occurrences_for_series(&mut self, series_id: &str) {
1544 for calendar in self
1545 .accounts
1546 .iter_mut()
1547 .flat_map(|account| &mut account.calendars)
1548 {
1549 calendar
1550 .events
1551 .retain(|event| event.series_master_app_id.as_deref() != Some(series_id));
1552 }
1553 }
1554
1555 fn metadata_for_event(&self, id: &str) -> Option<GoogleEventMetadata> {
1556 self.accounts
1557 .iter()
1558 .flat_map(|account| &account.calendars)
1559 .flat_map(|calendar| &calendar.events)
1560 .find(|event| event.id == id)
1561 .map(GoogleCachedEvent::metadata)
1562 }
1563
1564 fn event_by_id(&self, id: &str) -> Option<GoogleCachedEvent> {
1565 self.accounts
1566 .iter()
1567 .flat_map(|account| &account.calendars)
1568 .flat_map(|calendar| &calendar.events)
1569 .find(|event| event.id == id)
1570 .cloned()
1571 }
1572
1573 fn event_id_for_anchor(&self, series_id: &str, anchor: OccurrenceAnchor) -> Option<String> {
1574 self.accounts
1575 .iter()
1576 .flat_map(|account| &account.calendars)
1577 .flat_map(|calendar| &calendar.events)
1578 .find(|event| {
1579 event
1580 .occurrence_anchor()
1581 .map(|event_anchor| event_anchor == anchor)
1582 .unwrap_or(false)
1583 && event.series_master_app_id.as_deref() == Some(series_id)
1584 })
1585 .map(|event| event.id.clone())
1586 }
1587
1588 fn calendar_record(&self, account_id: &str, calendar_id: &str) -> Option<GoogleCalendarRecord> {
1589 self.accounts
1590 .iter()
1591 .find(|account| account.id == account_id)?
1592 .calendars
1593 .iter()
1594 .find(|calendar| calendar.id == calendar_id)
1595 .map(|calendar| GoogleCalendarRecord {
1596 id: calendar.id.clone(),
1597 name: calendar.name.clone(),
1598 can_edit: calendar.can_edit,
1599 is_default: calendar.is_default,
1600 })
1601 }
1602
1603 fn account_mut(&mut self, account_id: &str) -> &mut GoogleCacheAccount {
1604 if let Some(index) = self
1605 .accounts
1606 .iter()
1607 .position(|account| account.id == account_id)
1608 {
1609 &mut self.accounts[index]
1610 } else {
1611 self.accounts.push(GoogleCacheAccount {
1612 id: account_id.to_string(),
1613 calendars: Vec::new(),
1614 });
1615 self.accounts.last_mut().expect("account was pushed")
1616 }
1617 }
1618 }
1619
1620 #[derive(Debug, Clone, Serialize, Deserialize)]
1621 struct GoogleCacheAccount {
1622 id: String,
1623 #[serde(default)]
1624 calendars: Vec<GoogleCacheCalendar>,
1625 }
1626
1627 #[derive(Debug, Clone, Serialize, Deserialize)]
1628 struct GoogleCacheCalendar {
1629 id: String,
1630 name: String,
1631 #[serde(default)]
1632 can_edit: bool,
1633 #[serde(default)]
1634 is_default: bool,
1635 #[serde(default)]
1636 sync_token: Option<String>,
1637 #[serde(default)]
1638 last_synced_at_epoch_seconds: Option<u64>,
1639 #[serde(default)]
1640 events: Vec<GoogleCachedEvent>,
1641 }
1642
1643 #[derive(Debug, Clone, Serialize, Deserialize)]
1644 struct GoogleCachedEvent {
1645 id: String,
1646 account_id: String,
1647 calendar_id: String,
1648 calendar_name: String,
1649 google_id: String,
1650 #[serde(default)]
1651 event_type: Option<String>,
1652 #[serde(default)]
1653 recurring_event_id: Option<String>,
1654 #[serde(default)]
1655 series_master_app_id: Option<String>,
1656 #[serde(default)]
1657 status: Option<String>,
1658 title: String,
1659 timing: GoogleCachedTiming,
1660 #[serde(default)]
1661 original_start: Option<GoogleCachedTiming>,
1662 #[serde(default)]
1663 location: Option<String>,
1664 #[serde(default)]
1665 notes: Option<String>,
1666 #[serde(default)]
1667 reminders_minutes_before: Vec<u16>,
1668 #[serde(default)]
1669 recurrence: Option<MicrosoftCachedRecurrence>,
1670 #[serde(default)]
1671 raw: Value,
1672 }
1673
1674 impl GoogleCachedEvent {
1675 fn from_google(
1676 account_id: &str,
1677 calendar: &GoogleCalendarRecord,
1678 raw: Value,
1679 ) -> Result<Self, ProviderError> {
1680 let google_id = graph_string(&raw, "id")
1681 .ok_or_else(|| ProviderError::Mapping("Google event is missing id".to_string()))?;
1682 let title = graph_string(&raw, "summary").unwrap_or_else(|| "(Untitled)".to_string());
1683 let timing = google_timing(&raw, "start", "end")?;
1684 let original_start = raw
1685 .get("originalStartTime")
1686 .map(google_single_time)
1687 .transpose()?;
1688 let recurring_event_id = graph_string(&raw, "recurringEventId");
1689 let series_master_app_id = recurring_event_id
1690 .as_ref()
1691 .map(|id| google_event_app_id(account_id, &calendar.id, id));
1692 let recurrence = raw
1693 .get("recurrence")
1694 .and_then(Value::as_array)
1695 .and_then(|rules| {
1696 rules
1697 .iter()
1698 .filter_map(Value::as_str)
1699 .find(|rule| rule.starts_with("RRULE:"))
1700 })
1701 .and_then(google_rrule_to_cache);
1702 let reminders_minutes_before = raw
1703 .get("reminders")
1704 .and_then(|reminders| reminders.get("overrides"))
1705 .and_then(Value::as_array)
1706 .into_iter()
1707 .flatten()
1708 .filter_map(|reminder| graph_i64(reminder, "minutes"))
1709 .filter_map(|value| u16::try_from(value).ok())
1710 .collect();
1711
1712 Ok(Self {
1713 id: google_event_app_id(account_id, &calendar.id, &google_id),
1714 account_id: account_id.to_string(),
1715 calendar_id: calendar.id.clone(),
1716 calendar_name: calendar.name.clone(),
1717 google_id,
1718 event_type: if recurrence.is_some() && recurring_event_id.is_none() {
1719 Some("recurringMaster".to_string())
1720 } else {
1721 graph_string(&raw, "eventType")
1722 },
1723 recurring_event_id,
1724 series_master_app_id,
1725 status: graph_string(&raw, "status"),
1726 title,
1727 timing,
1728 original_start,
1729 location: graph_string(&raw, "location").filter(|value| !value.trim().is_empty()),
1730 notes: graph_string(&raw, "description").filter(|value| !value.trim().is_empty()),
1731 reminders_minutes_before,
1732 recurrence,
1733 raw,
1734 })
1735 }
1736
1737 fn to_event(&self) -> Option<Event> {
1738 if self.status.as_deref() == Some("cancelled") {
1739 return None;
1740 }
1741 let source = SourceMetadata::new(
1742 format!("google:{}:{}", self.account_id, self.calendar_id),
1743 format!("Google {}/{}", self.account_id, self.calendar_name),
1744 )
1745 .with_external_id(self.google_id.clone());
1746 let mut event = match self.timing {
1747 GoogleCachedTiming::AllDay { date } => {
1748 Event::all_day(self.id.clone(), self.title.clone(), date, source)
1749 }
1750 GoogleCachedTiming::Timed { start, end } => {
1751 Event::timed(self.id.clone(), self.title.clone(), start, end, source).ok()?
1752 }
1753 };
1754 event.location = self.location.clone();
1755 event.notes = self.notes.clone();
1756 event.reminders = self
1757 .reminders_minutes_before
1758 .iter()
1759 .copied()
1760 .map(Reminder::minutes_before)
1761 .collect();
1762 event.recurrence = self
1763 .recurrence
1764 .as_ref()
1765 .and_then(MicrosoftCachedRecurrence::to_rule);
1766 if let Some(series_master_app_id) = &self.series_master_app_id {
1767 event.occurrence = Some(OccurrenceMetadata {
1768 series_id: series_master_app_id.clone(),
1769 anchor: self.occurrence_anchor()?,
1770 });
1771 }
1772 Some(event)
1773 }
1774
1775 fn metadata(&self) -> GoogleEventMetadata {
1776 GoogleEventMetadata {
1777 account_id: self.account_id.clone(),
1778 calendar_id: self.calendar_id.clone(),
1779 google_id: self.google_id.clone(),
1780 recurring_event_id: self.recurring_event_id.clone(),
1781 occurrence_anchor: self.occurrence_anchor(),
1782 }
1783 }
1784
1785 fn occurrence_anchor(&self) -> Option<OccurrenceAnchor> {
1786 let timing = self.original_start.unwrap_or(self.timing);
1787 match timing {
1788 GoogleCachedTiming::AllDay { date } => Some(OccurrenceAnchor::AllDay { date }),
1789 GoogleCachedTiming::Timed { start, .. } => Some(OccurrenceAnchor::Timed { start }),
1790 }
1791 }
1792 }
1793
1794 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1795 #[serde(rename_all = "snake_case", tag = "kind")]
1796 enum GoogleCachedTiming {
1797 AllDay {
1798 date: CalendarDate,
1799 },
1800 Timed {
1801 start: EventDateTime,
1802 end: EventDateTime,
1803 },
1804 }
1805
1806 #[derive(Debug, Default, Clone, Serialize, Deserialize)]
1807 struct MicrosoftCacheFile {
1808 version: u8,
1809 #[serde(default)]
1810 accounts: Vec<MicrosoftCacheAccount>,
1811 }
1812
1813 impl MicrosoftCacheFile {
1814 fn empty() -> Self {
1815 Self {
1816 version: MICROSOFT_CACHE_VERSION,
1817 accounts: Vec::new(),
1818 }
1819 }
1820
1821 fn load(path: &Path) -> Result<Self, ProviderError> {
1822 let body = match fs::read_to_string(path) {
1823 Ok(body) => body,
1824 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
1825 Err(err) => {
1826 return Err(ProviderError::CacheRead {
1827 path: path.to_path_buf(),
1828 reason: err.to_string(),
1829 });
1830 }
1831 };
1832 let file =
1833 serde_json::from_str::<Self>(&body).map_err(|err| ProviderError::CacheParse {
1834 path: path.to_path_buf(),
1835 reason: err.to_string(),
1836 })?;
1837 if file.version != MICROSOFT_CACHE_VERSION {
1838 return Err(ProviderError::CacheParse {
1839 path: path.to_path_buf(),
1840 reason: format!("unsupported Microsoft cache version {}", file.version),
1841 });
1842 }
1843 Ok(file)
1844 }
1845
1846 fn save(&self, path: &Path) -> Result<(), ProviderError> {
1847 if let Some(parent) = path.parent() {
1848 fs::create_dir_all(parent).map_err(|err| ProviderError::CacheWrite {
1849 path: parent.to_path_buf(),
1850 reason: err.to_string(),
1851 })?;
1852 }
1853 let body = serde_json::to_string_pretty(self).map_err(|err| ProviderError::CacheWrite {
1854 path: path.to_path_buf(),
1855 reason: err.to_string(),
1856 })?;
1857 let temp_path = path.with_extension("json.tmp");
1858 fs::write(&temp_path, body).map_err(|err| ProviderError::CacheWrite {
1859 path: temp_path.clone(),
1860 reason: err.to_string(),
1861 })?;
1862 fs::rename(&temp_path, path).map_err(|err| ProviderError::CacheWrite {
1863 path: path.to_path_buf(),
1864 reason: err.to_string(),
1865 })
1866 }
1867
1868 fn replace_calendar(
1869 &mut self,
1870 account_id: &str,
1871 calendar: MicrosoftCalendarRecord,
1872 events: Vec<MicrosoftCachedEvent>,
1873 synced_at_epoch_seconds: u64,
1874 ) {
1875 let account = self.account_mut(account_id);
1876 if let Some(existing) = account
1877 .calendars
1878 .iter_mut()
1879 .find(|existing| existing.id == calendar.id)
1880 {
1881 existing.name = calendar.name;
1882 existing.can_edit = calendar.can_edit;
1883 existing.is_default = calendar.is_default;
1884 existing.last_synced_at_epoch_seconds = Some(synced_at_epoch_seconds);
1885 existing.events = events;
1886 } else {
1887 account.calendars.push(MicrosoftCacheCalendar {
1888 id: calendar.id,
1889 name: calendar.name,
1890 can_edit: calendar.can_edit,
1891 is_default: calendar.is_default,
1892 delta_link: None,
1893 last_synced_at_epoch_seconds: Some(synced_at_epoch_seconds),
1894 events,
1895 });
1896 }
1897 account
1898 .calendars
1899 .sort_by(|left, right| left.id.cmp(&right.id));
1900 }
1901
1902 fn upsert_event(
1903 &mut self,
1904 account_id: &str,
1905 calendar: MicrosoftCalendarRecord,
1906 event: MicrosoftCachedEvent,
1907 ) {
1908 let account = self.account_mut(account_id);
1909 let calendar_record = if let Some(existing) = account
1910 .calendars
1911 .iter_mut()
1912 .find(|existing| existing.id == calendar.id)
1913 {
1914 existing
1915 } else {
1916 account.calendars.push(MicrosoftCacheCalendar {
1917 id: calendar.id.clone(),
1918 name: calendar.name.clone(),
1919 can_edit: calendar.can_edit,
1920 is_default: calendar.is_default,
1921 delta_link: None,
1922 last_synced_at_epoch_seconds: None,
1923 events: Vec::new(),
1924 });
1925 account.calendars.last_mut().expect("calendar was pushed")
1926 };
1927 if let Some(existing) = calendar_record
1928 .events
1929 .iter_mut()
1930 .find(|existing| existing.id == event.id)
1931 {
1932 *existing = event;
1933 } else {
1934 calendar_record.events.push(event);
1935 }
1936 calendar_record
1937 .events
1938 .sort_by(|left, right| left.id.cmp(&right.id));
1939 }
1940
1941 fn remove_event(&mut self, id: &str) {
1942 for calendar in self
1943 .accounts
1944 .iter_mut()
1945 .flat_map(|account| &mut account.calendars)
1946 {
1947 calendar.events.retain(|event| {
1948 event.id != id && event.series_master_app_id.as_deref() != Some(id)
1949 });
1950 }
1951 }
1952
1953 fn remove_occurrences_for_series(&mut self, series_id: &str) {
1954 for calendar in self
1955 .accounts
1956 .iter_mut()
1957 .flat_map(|account| &mut account.calendars)
1958 {
1959 calendar
1960 .events
1961 .retain(|event| event.series_master_app_id.as_deref() != Some(series_id));
1962 }
1963 }
1964
1965 fn metadata_for_event(&self, id: &str) -> Option<MicrosoftEventMetadata> {
1966 self.accounts
1967 .iter()
1968 .flat_map(|account| &account.calendars)
1969 .flat_map(|calendar| &calendar.events)
1970 .find(|event| event.id == id)
1971 .map(MicrosoftCachedEvent::metadata)
1972 }
1973
1974 fn event_by_id(&self, id: &str) -> Option<MicrosoftCachedEvent> {
1975 self.accounts
1976 .iter()
1977 .flat_map(|account| &account.calendars)
1978 .flat_map(|calendar| &calendar.events)
1979 .find(|event| event.id == id)
1980 .cloned()
1981 }
1982
1983 fn event_id_for_anchor(&self, series_id: &str, anchor: OccurrenceAnchor) -> Option<String> {
1984 self.accounts
1985 .iter()
1986 .flat_map(|account| &account.calendars)
1987 .flat_map(|calendar| &calendar.events)
1988 .find(|event| {
1989 event
1990 .occurrence_anchor()
1991 .map(|event_anchor| event_anchor == anchor)
1992 .unwrap_or(false)
1993 && event.series_master_app_id.as_deref() == Some(series_id)
1994 })
1995 .map(|event| event.id.clone())
1996 }
1997
1998 fn calendar_record(
1999 &self,
2000 account_id: &str,
2001 calendar_id: &str,
2002 ) -> Option<MicrosoftCalendarRecord> {
2003 self.accounts
2004 .iter()
2005 .find(|account| account.id == account_id)?
2006 .calendars
2007 .iter()
2008 .find(|calendar| calendar.id == calendar_id)
2009 .map(|calendar| MicrosoftCalendarRecord {
2010 id: calendar.id.clone(),
2011 name: calendar.name.clone(),
2012 can_edit: calendar.can_edit,
2013 is_default: calendar.is_default,
2014 })
2015 }
2016
2017 fn account_mut(&mut self, account_id: &str) -> &mut MicrosoftCacheAccount {
2018 if let Some(index) = self
2019 .accounts
2020 .iter()
2021 .position(|account| account.id == account_id)
2022 {
2023 &mut self.accounts[index]
2024 } else {
2025 self.accounts.push(MicrosoftCacheAccount {
2026 id: account_id.to_string(),
2027 calendars: Vec::new(),
2028 });
2029 self.accounts.last_mut().expect("account was pushed")
2030 }
2031 }
2032 }
2033
2034 #[derive(Debug, Clone, Serialize, Deserialize)]
2035 struct MicrosoftCacheAccount {
2036 id: String,
2037 #[serde(default)]
2038 calendars: Vec<MicrosoftCacheCalendar>,
2039 }
2040
2041 #[derive(Debug, Clone, Serialize, Deserialize)]
2042 struct MicrosoftCacheCalendar {
2043 id: String,
2044 name: String,
2045 #[serde(default)]
2046 can_edit: bool,
2047 #[serde(default)]
2048 is_default: bool,
2049 #[serde(default)]
2050 delta_link: Option<String>,
2051 #[serde(default)]
2052 last_synced_at_epoch_seconds: Option<u64>,
2053 #[serde(default)]
2054 events: Vec<MicrosoftCachedEvent>,
2055 }
2056
2057 #[derive(Debug, Clone, Serialize, Deserialize)]
2058 struct MicrosoftCachedEvent {
2059 id: String,
2060 account_id: String,
2061 calendar_id: String,
2062 calendar_name: String,
2063 graph_id: String,
2064 #[serde(default)]
2065 event_type: Option<String>,
2066 #[serde(default)]
2067 series_master_id: Option<String>,
2068 #[serde(default)]
2069 series_master_app_id: Option<String>,
2070 #[serde(default)]
2071 occurrence_id: Option<String>,
2072 #[serde(default)]
2073 change_key: Option<String>,
2074 title: String,
2075 timing: MicrosoftCachedTiming,
2076 #[serde(default)]
2077 location: Option<String>,
2078 #[serde(default)]
2079 notes: Option<String>,
2080 #[serde(default)]
2081 reminders_minutes_before: Vec<u16>,
2082 #[serde(default)]
2083 recurrence: Option<MicrosoftCachedRecurrence>,
2084 #[serde(default)]
2085 raw: Value,
2086 }
2087
2088 impl MicrosoftCachedEvent {
2089 fn from_graph(
2090 account_id: &str,
2091 calendar: &MicrosoftCalendarRecord,
2092 raw: Value,
2093 ) -> Result<Self, ProviderError> {
2094 let graph_id = graph_string(&raw, "id")
2095 .ok_or_else(|| ProviderError::Mapping("Graph event is missing id".to_string()))?;
2096 let event_type = graph_string(&raw, "type");
2097 let title = graph_string(&raw, "subject").unwrap_or_else(|| "(Untitled)".to_string());
2098 let is_all_day = graph_bool(&raw, "isAllDay").unwrap_or(false);
2099 let start = graph_datetime(&raw, "start")?;
2100 let end = graph_datetime(&raw, "end")?;
2101 let timing = if is_all_day {
2102 MicrosoftCachedTiming::AllDay { date: start.date }
2103 } else {
2104 MicrosoftCachedTiming::Timed { start, end }
2105 };
2106 let series_master_id = graph_string(&raw, "seriesMasterId");
2107 let series_master_app_id = series_master_id
2108 .as_ref()
2109 .map(|id| microsoft_event_app_id(account_id, &calendar.id, id));
2110 let recurrence = raw
2111 .get("recurrence")
2112 .filter(|value| !value.is_null())
2113 .map(graph_recurrence_to_cache)
2114 .transpose()?;
2115
2116 let reminders_minutes_before = if graph_bool(&raw, "isReminderOn").unwrap_or(false) {
2117 graph_i64(&raw, "reminderMinutesBeforeStart")
2118 .and_then(|value| u16::try_from(value).ok())
2119 .into_iter()
2120 .collect()
2121 } else {
2122 Vec::new()
2123 };
2124
2125 Ok(Self {
2126 id: microsoft_event_app_id(account_id, &calendar.id, &graph_id),
2127 account_id: account_id.to_string(),
2128 calendar_id: calendar.id.clone(),
2129 calendar_name: calendar.name.clone(),
2130 graph_id,
2131 event_type,
2132 series_master_id,
2133 series_master_app_id,
2134 occurrence_id: graph_string(&raw, "occurrenceId"),
2135 change_key: graph_string(&raw, "changeKey"),
2136 title,
2137 timing,
2138 location: raw
2139 .get("location")
2140 .and_then(|location| graph_string(location, "displayName"))
2141 .filter(|value| !value.trim().is_empty()),
2142 notes: graph_string(&raw, "bodyPreview")
2143 .or_else(|| {
2144 raw.get("body")
2145 .and_then(|body| graph_string(body, "content"))
2146 })
2147 .filter(|value| !value.trim().is_empty()),
2148 reminders_minutes_before,
2149 recurrence,
2150 raw,
2151 })
2152 }
2153
2154 fn to_event(&self) -> Option<Event> {
2155 let source = SourceMetadata::new(
2156 format!("microsoft:{}:{}", self.account_id, self.calendar_id),
2157 format!("Microsoft {}/{}", self.account_id, self.calendar_name),
2158 )
2159 .with_external_id(self.graph_id.clone());
2160 let mut event = match self.timing {
2161 MicrosoftCachedTiming::AllDay { date } => {
2162 Event::all_day(self.id.clone(), self.title.clone(), date, source)
2163 }
2164 MicrosoftCachedTiming::Timed { start, end } => {
2165 Event::timed(self.id.clone(), self.title.clone(), start, end, source).ok()?
2166 }
2167 };
2168 event.location = self.location.clone();
2169 event.notes = self.notes.clone();
2170 event.reminders = self
2171 .reminders_minutes_before
2172 .iter()
2173 .copied()
2174 .map(Reminder::minutes_before)
2175 .collect();
2176 event.recurrence = self
2177 .recurrence
2178 .as_ref()
2179 .and_then(MicrosoftCachedRecurrence::to_rule);
2180 if let Some(series_master_app_id) = &self.series_master_app_id {
2181 event.occurrence = Some(OccurrenceMetadata {
2182 series_id: series_master_app_id.clone(),
2183 anchor: self.occurrence_anchor()?,
2184 });
2185 }
2186 Some(event)
2187 }
2188
2189 fn metadata(&self) -> MicrosoftEventMetadata {
2190 MicrosoftEventMetadata {
2191 account_id: self.account_id.clone(),
2192 calendar_id: self.calendar_id.clone(),
2193 graph_id: self.graph_id.clone(),
2194 series_master_id: self.series_master_id.clone(),
2195 occurrence_anchor: self.occurrence_anchor(),
2196 }
2197 }
2198
2199 fn occurrence_anchor(&self) -> Option<OccurrenceAnchor> {
2200 match self.timing {
2201 MicrosoftCachedTiming::AllDay { date } => Some(OccurrenceAnchor::AllDay { date }),
2202 MicrosoftCachedTiming::Timed { start, .. } => Some(OccurrenceAnchor::Timed { start }),
2203 }
2204 }
2205 }
2206
2207 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2208 #[serde(rename_all = "snake_case", tag = "kind")]
2209 enum MicrosoftCachedTiming {
2210 AllDay {
2211 date: CalendarDate,
2212 },
2213 Timed {
2214 start: EventDateTime,
2215 end: EventDateTime,
2216 },
2217 }
2218
2219 #[derive(Debug, Clone, Serialize, Deserialize)]
2220 struct MicrosoftCachedRecurrence {
2221 frequency: RecurrenceFrequencyRecord,
2222 interval: u16,
2223 end: RecurrenceEndRecord,
2224 #[serde(default)]
2225 weekdays: Vec<WeekdayRecord>,
2226 #[serde(default)]
2227 monthly: Option<MonthlyRuleRecord>,
2228 #[serde(default)]
2229 yearly: Option<YearlyRuleRecord>,
2230 }
2231
2232 impl MicrosoftCachedRecurrence {
2233 fn to_rule(&self) -> Option<RecurrenceRule> {
2234 let frequency = match self.frequency {
2235 RecurrenceFrequencyRecord::Daily => RecurrenceFrequency::Daily,
2236 RecurrenceFrequencyRecord::Weekly => RecurrenceFrequency::Weekly,
2237 RecurrenceFrequencyRecord::Monthly => RecurrenceFrequency::Monthly,
2238 RecurrenceFrequencyRecord::Yearly => RecurrenceFrequency::Yearly,
2239 };
2240 Some(RecurrenceRule {
2241 frequency,
2242 interval: self.interval.max(1),
2243 end: self.end.to_rule()?,
2244 weekdays: self
2245 .weekdays
2246 .iter()
2247 .copied()
2248 .map(WeekdayRecord::to_weekday)
2249 .collect::<Option<Vec<_>>>()?,
2250 monthly: self.monthly.and_then(MonthlyRuleRecord::to_rule),
2251 yearly: self.yearly.and_then(YearlyRuleRecord::to_rule),
2252 })
2253 }
2254 }
2255
2256 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2257 #[serde(rename_all = "snake_case")]
2258 enum RecurrenceFrequencyRecord {
2259 Daily,
2260 Weekly,
2261 Monthly,
2262 Yearly,
2263 }
2264
2265 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2266 #[serde(rename_all = "snake_case", tag = "kind")]
2267 enum RecurrenceEndRecord {
2268 Never,
2269 Until { date: CalendarDate },
2270 Count { count: u32 },
2271 }
2272
2273 impl RecurrenceEndRecord {
2274 fn to_rule(self) -> Option<RecurrenceEnd> {
2275 match self {
2276 Self::Never => Some(RecurrenceEnd::Never),
2277 Self::Until { date } => Some(RecurrenceEnd::Until(date)),
2278 Self::Count { count } => Some(RecurrenceEnd::Count(count)),
2279 }
2280 }
2281 }
2282
2283 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2284 #[serde(rename_all = "snake_case")]
2285 enum WeekdayRecord {
2286 Sunday,
2287 Monday,
2288 Tuesday,
2289 Wednesday,
2290 Thursday,
2291 Friday,
2292 Saturday,
2293 }
2294
2295 impl WeekdayRecord {
2296 fn from_weekday(weekday: Weekday) -> Self {
2297 match weekday {
2298 Weekday::Sunday => Self::Sunday,
2299 Weekday::Monday => Self::Monday,
2300 Weekday::Tuesday => Self::Tuesday,
2301 Weekday::Wednesday => Self::Wednesday,
2302 Weekday::Thursday => Self::Thursday,
2303 Weekday::Friday => Self::Friday,
2304 Weekday::Saturday => Self::Saturday,
2305 }
2306 }
2307
2308 fn to_weekday(self) -> Option<Weekday> {
2309 Some(match self {
2310 Self::Sunday => Weekday::Sunday,
2311 Self::Monday => Weekday::Monday,
2312 Self::Tuesday => Weekday::Tuesday,
2313 Self::Wednesday => Weekday::Wednesday,
2314 Self::Thursday => Weekday::Thursday,
2315 Self::Friday => Weekday::Friday,
2316 Self::Saturday => Weekday::Saturday,
2317 })
2318 }
2319 }
2320
2321 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2322 #[serde(rename_all = "snake_case", tag = "kind")]
2323 enum MonthlyRuleRecord {
2324 DayOfMonth {
2325 day: u8,
2326 },
2327 WeekdayOrdinal {
2328 ordinal: OrdinalRecord,
2329 weekday: WeekdayRecord,
2330 },
2331 }
2332
2333 impl MonthlyRuleRecord {
2334 fn to_rule(self) -> Option<RecurrenceMonthlyRule> {
2335 match self {
2336 Self::DayOfMonth { day } => Some(RecurrenceMonthlyRule::DayOfMonth(day)),
2337 Self::WeekdayOrdinal { ordinal, weekday } => {
2338 Some(RecurrenceMonthlyRule::WeekdayOrdinal {
2339 ordinal: ordinal.to_rule(),
2340 weekday: weekday.to_weekday()?,
2341 })
2342 }
2343 }
2344 }
2345 }
2346
2347 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2348 #[serde(rename_all = "snake_case", tag = "kind")]
2349 enum YearlyRuleRecord {
2350 Date {
2351 month: u8,
2352 day: u8,
2353 },
2354 WeekdayOrdinal {
2355 month: u8,
2356 ordinal: OrdinalRecord,
2357 weekday: WeekdayRecord,
2358 },
2359 }
2360
2361 impl YearlyRuleRecord {
2362 fn to_rule(self) -> Option<RecurrenceYearlyRule> {
2363 match self {
2364 Self::Date { month, day } => Some(RecurrenceYearlyRule::Date {
2365 month: Month::try_from(month).ok()?,
2366 day,
2367 }),
2368 Self::WeekdayOrdinal {
2369 month,
2370 ordinal,
2371 weekday,
2372 } => Some(RecurrenceYearlyRule::WeekdayOrdinal {
2373 month: Month::try_from(month).ok()?,
2374 ordinal: ordinal.to_rule(),
2375 weekday: weekday.to_weekday()?,
2376 }),
2377 }
2378 }
2379 }
2380
2381 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
2382 #[serde(rename_all = "snake_case")]
2383 enum OrdinalRecord {
2384 First,
2385 Second,
2386 Third,
2387 Fourth,
2388 Last,
2389 }
2390
2391 impl OrdinalRecord {
2392 fn from_rule(ordinal: RecurrenceOrdinal) -> Self {
2393 match ordinal {
2394 RecurrenceOrdinal::Number(1) => Self::First,
2395 RecurrenceOrdinal::Number(2) => Self::Second,
2396 RecurrenceOrdinal::Number(3) => Self::Third,
2397 RecurrenceOrdinal::Number(_) => Self::Fourth,
2398 RecurrenceOrdinal::Last => Self::Last,
2399 }
2400 }
2401
2402 fn to_rule(self) -> RecurrenceOrdinal {
2403 match self {
2404 Self::First => RecurrenceOrdinal::Number(1),
2405 Self::Second => RecurrenceOrdinal::Number(2),
2406 Self::Third => RecurrenceOrdinal::Number(3),
2407 Self::Fourth => RecurrenceOrdinal::Number(4),
2408 Self::Last => RecurrenceOrdinal::Last,
2409 }
2410 }
2411 }
2412
2413 #[derive(Debug, Clone)]
2414 struct MicrosoftCalendarRecord {
2415 id: String,
2416 name: String,
2417 can_edit: bool,
2418 is_default: bool,
2419 }
2420
2421 #[derive(Debug, Clone)]
2422 struct GoogleCalendarRecord {
2423 id: String,
2424 name: String,
2425 can_edit: bool,
2426 is_default: bool,
2427 }
2428
2429 pub trait MicrosoftTokenStore {
2430 fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError>;
2431 fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError>;
2432 fn delete(&self, account_id: &str) -> Result<(), ProviderError>;
2433 }
2434
2435 #[derive(Debug, Default)]
2436 pub struct KeyringMicrosoftTokenStore;
2437
2438 impl MicrosoftTokenStore for KeyringMicrosoftTokenStore {
2439 fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError> {
2440 let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
2441 .map_err(|err| ProviderError::Keyring(err.to_string()))?;
2442 match entry.get_password() {
2443 Ok(body) => serde_json::from_str(&body)
2444 .map(Some)
2445 .map_err(|err| ProviderError::Keyring(err.to_string())),
2446 Err(keyring::Error::NoEntry) => Ok(None),
2447 Err(err) => Err(ProviderError::Keyring(err.to_string())),
2448 }
2449 }
2450
2451 fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError> {
2452 let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
2453 .map_err(|err| ProviderError::Keyring(err.to_string()))?;
2454 let body =
2455 serde_json::to_string(token).map_err(|err| ProviderError::Keyring(err.to_string()))?;
2456 entry
2457 .set_password(&body)
2458 .map_err(|err| ProviderError::Keyring(err.to_string()))
2459 }
2460
2461 fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
2462 let entry = keyring::Entry::new(KEYRING_SERVICE, account_id)
2463 .map_err(|err| ProviderError::Keyring(err.to_string()))?;
2464 match entry.delete_credential() {
2465 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
2466 Err(err) => Err(ProviderError::Keyring(err.to_string())),
2467 }
2468 }
2469 }
2470
2471 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2472 pub struct MicrosoftToken {
2473 pub access_token: String,
2474 pub refresh_token: String,
2475 pub expires_at_epoch_seconds: u64,
2476 }
2477
2478 pub trait GoogleTokenStore {
2479 fn load(&self, account_id: &str) -> Result<Option<GoogleToken>, ProviderError>;
2480 fn save(&self, account_id: &str, token: &GoogleToken) -> Result<(), ProviderError>;
2481 fn delete(&self, account_id: &str) -> Result<(), ProviderError>;
2482 }
2483
2484 #[derive(Debug, Default)]
2485 pub struct KeyringGoogleTokenStore;
2486
2487 impl GoogleTokenStore for KeyringGoogleTokenStore {
2488 fn load(&self, account_id: &str) -> Result<Option<GoogleToken>, ProviderError> {
2489 let entry = keyring::Entry::new(GOOGLE_KEYRING_SERVICE, account_id)
2490 .map_err(|err| ProviderError::Keyring(format!("Google: {err}")))?;
2491 match entry.get_password() {
2492 Ok(body) => serde_json::from_str(&body)
2493 .map(Some)
2494 .map_err(|err| ProviderError::Keyring(format!("Google: {err}"))),
2495 Err(keyring::Error::NoEntry) => Ok(None),
2496 Err(err) => Err(ProviderError::Keyring(format!("Google: {err}"))),
2497 }
2498 }
2499
2500 fn save(&self, account_id: &str, token: &GoogleToken) -> Result<(), ProviderError> {
2501 let entry = keyring::Entry::new(GOOGLE_KEYRING_SERVICE, account_id)
2502 .map_err(|err| ProviderError::Keyring(format!("Google: {err}")))?;
2503 let body = serde_json::to_string(token)
2504 .map_err(|err| ProviderError::Keyring(format!("Google: {err}")))?;
2505 entry
2506 .set_password(&body)
2507 .map_err(|err| ProviderError::Keyring(format!("Google: {err}")))
2508 }
2509
2510 fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
2511 let entry = keyring::Entry::new(GOOGLE_KEYRING_SERVICE, account_id)
2512 .map_err(|err| ProviderError::Keyring(format!("Google: {err}")))?;
2513 match entry.delete_credential() {
2514 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
2515 Err(err) => Err(ProviderError::Keyring(format!("Google: {err}"))),
2516 }
2517 }
2518 }
2519
2520 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2521 pub struct GoogleToken {
2522 pub access_token: String,
2523 pub refresh_token: String,
2524 pub expires_at_epoch_seconds: u64,
2525 }
2526
2527 pub trait MicrosoftHttpClient {
2528 fn request(
2529 &self,
2530 request: MicrosoftHttpRequest,
2531 ) -> Result<MicrosoftHttpResponse, ProviderError>;
2532 }
2533
2534 #[derive(Debug, Clone, PartialEq, Eq)]
2535 pub struct MicrosoftHttpRequest {
2536 pub method: String,
2537 pub url: String,
2538 pub headers: Vec<(String, String)>,
2539 pub body: Option<String>,
2540 }
2541
2542 #[derive(Debug, Clone, PartialEq, Eq)]
2543 pub struct MicrosoftHttpResponse {
2544 pub status: u16,
2545 pub headers: Vec<(String, String)>,
2546 pub body: String,
2547 }
2548
2549 #[derive(Debug, Default)]
2550 pub struct ReqwestMicrosoftHttpClient;
2551
2552 impl MicrosoftHttpClient for ReqwestMicrosoftHttpClient {
2553 fn request(
2554 &self,
2555 request: MicrosoftHttpRequest,
2556 ) -> Result<MicrosoftHttpResponse, ProviderError> {
2557 let client = reqwest::blocking::Client::builder()
2558 .timeout(StdDuration::from_secs(20))
2559 .user_agent("rcal/0.1")
2560 .build()
2561 .map_err(|err| ProviderError::Http(err.to_string()))?;
2562 let method = reqwest::Method::from_bytes(request.method.as_bytes())
2563 .map_err(|err| ProviderError::Http(err.to_string()))?;
2564 let mut builder = client.request(method, request.url);
2565 for (name, value) in request.headers {
2566 builder = builder.header(name, value);
2567 }
2568 if let Some(body) = request.body {
2569 builder = builder.body(body);
2570 }
2571 let response = builder
2572 .send()
2573 .map_err(|err| ProviderError::Http(err.to_string()))?;
2574 let status = response.status().as_u16();
2575 let headers = response
2576 .headers()
2577 .iter()
2578 .map(|(name, value)| {
2579 (
2580 name.as_str().to_string(),
2581 value.to_str().unwrap_or_default().to_string(),
2582 )
2583 })
2584 .collect();
2585 let body = response
2586 .text()
2587 .map_err(|err| ProviderError::Http(err.to_string()))?;
2588 Ok(MicrosoftHttpResponse {
2589 status,
2590 headers,
2591 body,
2592 })
2593 }
2594 }
2595
2596 pub fn login_device_code_or_browser(
2597 account: &MicrosoftAccountConfig,
2598 http: &dyn MicrosoftHttpClient,
2599 token_store: &dyn MicrosoftTokenStore,
2600 stdout: &mut dyn Write,
2601 prefer_browser: bool,
2602 ) -> Result<(), ProviderError> {
2603 let result = if prefer_browser {
2604 login_browser(account, http, token_store, stdout)
2605 } else {
2606 login_device_code(account, http, token_store, stdout)
2607 };
2608 match result {
2609 Ok(()) => Ok(()),
2610 Err(err) if !prefer_browser => {
2611 let _ = writeln!(stdout, "device-code login failed: {err}");
2612 let _ = writeln!(stdout, "falling back to browser login");
2613 login_browser(account, http, token_store, stdout)
2614 }
2615 Err(err) => Err(err),
2616 }
2617 }
2618
2619 pub fn logout(
2620 account_id: &str,
2621 token_store: &dyn MicrosoftTokenStore,
2622 ) -> Result<(), ProviderError> {
2623 token_store.delete(account_id)
2624 }
2625
2626 #[derive(Debug, Clone, PartialEq, Eq)]
2627 pub struct MicrosoftTokenInspection {
2628 pub account_id: String,
2629 pub token_format: String,
2630 pub stored_expires_at_epoch_seconds: u64,
2631 pub jwt_expires_at_epoch_seconds: Option<i64>,
2632 pub audience: Option<String>,
2633 pub scopes: Option<String>,
2634 pub roles: Vec<String>,
2635 pub tenant_id: Option<String>,
2636 pub issuer: Option<String>,
2637 pub app_id: Option<String>,
2638 pub authorized_party: Option<String>,
2639 pub has_refresh_token: bool,
2640 }
2641
2642 pub fn inspect_token(
2643 account_id: &str,
2644 token_store: &dyn MicrosoftTokenStore,
2645 ) -> Result<MicrosoftTokenInspection, ProviderError> {
2646 let token = token_store.load(account_id)?.ok_or_else(|| {
2647 ProviderError::Auth(format!(
2648 "Microsoft account '{account_id}' is not authenticated"
2649 ))
2650 })?;
2651 let claims = access_token_claims(&token.access_token)?;
2652 let claims_ref = claims.as_ref();
2653 Ok(MicrosoftTokenInspection {
2654 account_id: account_id.to_string(),
2655 token_format: if claims_ref.is_some() {
2656 "jwt"
2657 } else {
2658 "opaque"
2659 }
2660 .to_string(),
2661 stored_expires_at_epoch_seconds: token.expires_at_epoch_seconds,
2662 jwt_expires_at_epoch_seconds: claims_ref.and_then(|claims| graph_i64(claims, "exp")),
2663 audience: claims_ref.and_then(|claims| graph_string(claims, "aud")),
2664 scopes: claims_ref.and_then(|claims| graph_string(claims, "scp")),
2665 roles: claims_ref
2666 .and_then(|claims| claims.get("roles"))
2667 .and_then(Value::as_array)
2668 .map(|roles| {
2669 roles
2670 .iter()
2671 .filter_map(Value::as_str)
2672 .map(ToString::to_string)
2673 .collect()
2674 })
2675 .unwrap_or_default(),
2676 tenant_id: claims_ref.and_then(|claims| graph_string(claims, "tid")),
2677 issuer: claims_ref.and_then(|claims| graph_string(claims, "iss")),
2678 app_id: claims_ref.and_then(|claims| graph_string(claims, "appid")),
2679 authorized_party: claims_ref.and_then(|claims| graph_string(claims, "azp")),
2680 has_refresh_token: !token.refresh_token.is_empty(),
2681 })
2682 }
2683
2684 fn login_device_code(
2685 account: &MicrosoftAccountConfig,
2686 http: &dyn MicrosoftHttpClient,
2687 token_store: &dyn MicrosoftTokenStore,
2688 stdout: &mut dyn Write,
2689 ) -> Result<(), ProviderError> {
2690 let body = form_body(&[
2691 ("client_id", account.client_id.as_str()),
2692 ("scope", MICROSOFT_SCOPES),
2693 ]);
2694 let response = http.request(MicrosoftHttpRequest {
2695 method: "POST".to_string(),
2696 url: account.device_code_url(),
2697 headers: vec![(
2698 "Content-Type".to_string(),
2699 "application/x-www-form-urlencoded".to_string(),
2700 )],
2701 body: Some(body),
2702 })?;
2703 let value = parse_oauth_json(response)?;
2704 let device_code = required_json_string(&value, "device_code")?;
2705 let user_code = required_json_string(&value, "user_code")?;
2706 let verification_uri = graph_string(&value, "verification_uri")
2707 .or_else(|| graph_string(&value, "verification_url"))
2708 .ok_or_else(|| {
2709 ProviderError::Auth("device-code response is missing verification URL".to_string())
2710 })?;
2711 let message = graph_string(&value, "message")
2712 .unwrap_or_else(|| format!("Visit {verification_uri} and enter code {user_code}"));
2713 let expires_in = graph_i64(&value, "expires_in").unwrap_or(900).max(1) as u64;
2714 let mut interval = graph_i64(&value, "interval").unwrap_or(5).max(1) as u64;
2715 writeln!(stdout, "{message}").map_err(|err| ProviderError::Auth(err.to_string()))?;
2716
2717 let started = current_epoch_seconds();
2718 loop {
2719 if current_epoch_seconds().saturating_sub(started) > expires_in {
2720 return Err(ProviderError::Auth(
2721 "device-code login timed out".to_string(),
2722 ));
2723 }
2724 thread::sleep(StdDuration::from_secs(interval));
2725 let body = form_body(&[
2726 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
2727 ("client_id", account.client_id.as_str()),
2728 ("device_code", &device_code),
2729 ]);
2730 let response = http.request(MicrosoftHttpRequest {
2731 method: "POST".to_string(),
2732 url: account.token_url(),
2733 headers: vec![(
2734 "Content-Type".to_string(),
2735 "application/x-www-form-urlencoded".to_string(),
2736 )],
2737 body: Some(body),
2738 })?;
2739 if response.status == 200 {
2740 let token = token_from_response(response)?;
2741 token_store.save(&account.id, &token)?;
2742 writeln!(stdout, "authenticated Microsoft account '{}'", account.id)
2743 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2744 return Ok(());
2745 }
2746 let value = serde_json::from_str::<Value>(&response.body)
2747 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2748 match graph_string(&value, "error").as_deref() {
2749 Some("authorization_pending") => {}
2750 Some("slow_down") => interval = interval.saturating_add(5),
2751 Some("authorization_declined") => {
2752 return Err(ProviderError::Auth(
2753 "authorization was declined".to_string(),
2754 ));
2755 }
2756 Some("expired_token") => {
2757 return Err(ProviderError::Auth("device code expired".to_string()));
2758 }
2759 Some(error) => {
2760 return Err(ProviderError::Auth(format!(
2761 "{error}: {}",
2762 graph_string(&value, "error_description").unwrap_or_default()
2763 )));
2764 }
2765 None => return Err(ProviderError::Auth(response.body)),
2766 }
2767 }
2768 }
2769
2770 fn login_browser(
2771 account: &MicrosoftAccountConfig,
2772 http: &dyn MicrosoftHttpClient,
2773 token_store: &dyn MicrosoftTokenStore,
2774 stdout: &mut dyn Write,
2775 ) -> Result<(), ProviderError> {
2776 let verifier = pkce_verifier();
2777 let challenge = pkce_challenge(&verifier);
2778 let state = pkce_verifier();
2779 let redirect_uri = account.redirect_uri();
2780 let listener = TcpListener::bind(("127.0.0.1", account.redirect_port)).map_err(|err| {
2781 ProviderError::Auth(format!("failed to listen for OAuth callback: {err}"))
2782 })?;
2783 let auth_url = format!(
2784 "{}?client_id={}&response_type=code&redirect_uri={}&response_mode=query&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
2785 account.authorize_url(),
2786 percent_encode(&account.client_id),
2787 percent_encode(&redirect_uri),
2788 percent_encode(MICROSOFT_SCOPES),
2789 percent_encode(&state),
2790 percent_encode(&challenge),
2791 );
2792 writeln!(stdout, "opening browser for Microsoft login")
2793 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2794 open_browser(&auth_url)?;
2795 let (mut stream, _) = listener
2796 .accept()
2797 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2798 let mut request = String::new();
2799 BufReader::new(
2800 stream
2801 .try_clone()
2802 .map_err(|err| ProviderError::Auth(err.to_string()))?,
2803 )
2804 .read_line(&mut request)
2805 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2806 let query = request
2807 .split_whitespace()
2808 .nth(1)
2809 .and_then(|path| path.split_once('?').map(|(_, query)| query))
2810 .ok_or_else(|| ProviderError::Auth("OAuth callback did not include a query".to_string()))?;
2811 let params = parse_query(query);
2812 if let Some(error) = params.get("error") {
2813 let description = params
2814 .get("error_description")
2815 .map(String::as_str)
2816 .unwrap_or("Microsoft did not provide an error description");
2817 let message = format!("{error}: {description}");
2818 let _ = write_oauth_callback_response(&mut stream, "Microsoft", false, &message);
2819 return Err(ProviderError::Auth(message));
2820 }
2821 let code = params.get("code").ok_or_else(|| {
2822 let message = "OAuth callback did not include code".to_string();
2823 let _ = write_oauth_callback_response(&mut stream, "Microsoft", false, &message);
2824 ProviderError::Auth(message)
2825 })?;
2826 if params.get("state") != Some(&state) {
2827 let message = "OAuth callback state mismatch".to_string();
2828 let _ = write_oauth_callback_response(&mut stream, "Microsoft", false, &message);
2829 return Err(ProviderError::Auth(message));
2830 }
2831 let _ = write_oauth_callback_response(
2832 &mut stream,
2833 "Microsoft",
2834 true,
2835 "rcal Microsoft login complete. You can close this tab.",
2836 );
2837 let body = form_body(&[
2838 ("grant_type", "authorization_code"),
2839 ("client_id", account.client_id.as_str()),
2840 ("scope", MICROSOFT_SCOPES),
2841 ("code", code),
2842 ("redirect_uri", &redirect_uri),
2843 ("code_verifier", &verifier),
2844 ]);
2845 let response = http.request(MicrosoftHttpRequest {
2846 method: "POST".to_string(),
2847 url: account.token_url(),
2848 headers: vec![(
2849 "Content-Type".to_string(),
2850 "application/x-www-form-urlencoded".to_string(),
2851 )],
2852 body: Some(body),
2853 })?;
2854 let token = token_from_response(response)?;
2855 token_store.save(&account.id, &token)?;
2856 writeln!(stdout, "authenticated Microsoft account '{}'", account.id)
2857 .map_err(|err| ProviderError::Auth(err.to_string()))
2858 }
2859
2860 fn access_token(
2861 account: &MicrosoftAccountConfig,
2862 http: &dyn MicrosoftHttpClient,
2863 token_store: &dyn MicrosoftTokenStore,
2864 ) -> Result<String, ProviderError> {
2865 let token = token_store.load(&account.id)?.ok_or_else(|| {
2866 ProviderError::Auth(format!(
2867 "Microsoft account '{}' is not authenticated",
2868 account.id
2869 ))
2870 })?;
2871 if token.expires_at_epoch_seconds > current_epoch_seconds().saturating_add(120) {
2872 return Ok(token.access_token);
2873 }
2874 let body = form_body(&[
2875 ("grant_type", "refresh_token"),
2876 ("client_id", account.client_id.as_str()),
2877 ("refresh_token", token.refresh_token.as_str()),
2878 ("scope", MICROSOFT_SCOPES),
2879 ]);
2880 let response = http.request(MicrosoftHttpRequest {
2881 method: "POST".to_string(),
2882 url: account.token_url(),
2883 headers: vec![(
2884 "Content-Type".to_string(),
2885 "application/x-www-form-urlencoded".to_string(),
2886 )],
2887 body: Some(body),
2888 })?;
2889 let refreshed = token_from_response(response)?;
2890 token_store.save(&account.id, &refreshed)?;
2891 Ok(refreshed.access_token)
2892 }
2893
2894 pub fn list_calendars(
2895 account: &MicrosoftAccountConfig,
2896 http: &dyn MicrosoftHttpClient,
2897 token_store: &dyn MicrosoftTokenStore,
2898 ) -> Result<Vec<MicrosoftCalendarInfo>, ProviderError> {
2899 let token = access_token(account, http, token_store)?;
2900 let response = graph_request(
2901 http,
2902 "GET",
2903 &format!("{GRAPH_BASE_URL}/me/calendars?$select=id,name,canEdit,isDefaultCalendar"),
2904 &token,
2905 None,
2906 )?;
2907 let value = parse_graph_success_json(response)?;
2908 let calendars = value
2909 .get("value")
2910 .and_then(Value::as_array)
2911 .ok_or_else(|| {
2912 ProviderError::Mapping("calendar list response is missing value".to_string())
2913 })?
2914 .iter()
2915 .map(|calendar| MicrosoftCalendarInfo {
2916 id: graph_string(calendar, "id").unwrap_or_default(),
2917 name: graph_string(calendar, "name").unwrap_or_default(),
2918 can_edit: graph_bool(calendar, "canEdit").unwrap_or(false),
2919 is_default: graph_bool(calendar, "isDefaultCalendar").unwrap_or(false),
2920 })
2921 .filter(|calendar| !calendar.id.is_empty())
2922 .collect::<Vec<_>>();
2923 Ok(calendars)
2924 }
2925
2926 pub fn login_google_browser(
2927 account: &GoogleAccountConfig,
2928 http: &dyn MicrosoftHttpClient,
2929 token_store: &dyn GoogleTokenStore,
2930 stdout: &mut dyn Write,
2931 ) -> Result<(), ProviderError> {
2932 let verifier = pkce_verifier();
2933 let challenge = pkce_challenge(&verifier);
2934 let state = pkce_verifier();
2935 let redirect_uri = account.redirect_uri();
2936 let listener = TcpListener::bind(("127.0.0.1", account.redirect_port)).map_err(|err| {
2937 ProviderError::Auth(format!("failed to listen for Google OAuth callback: {err}"))
2938 })?;
2939 let auth_url = format!(
2940 "{GOOGLE_AUTH_URL}?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256&access_type=offline&prompt=consent",
2941 percent_encode(&account.client_id),
2942 percent_encode(&redirect_uri),
2943 percent_encode(GOOGLE_SCOPES),
2944 percent_encode(&state),
2945 percent_encode(&challenge),
2946 );
2947 writeln!(stdout, "opening browser for Google login")
2948 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2949 open_browser(&auth_url)?;
2950 let (mut stream, _) = listener
2951 .accept()
2952 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2953 let mut request = String::new();
2954 BufReader::new(
2955 stream
2956 .try_clone()
2957 .map_err(|err| ProviderError::Auth(err.to_string()))?,
2958 )
2959 .read_line(&mut request)
2960 .map_err(|err| ProviderError::Auth(err.to_string()))?;
2961 let query = request
2962 .split_whitespace()
2963 .nth(1)
2964 .and_then(|path| path.split_once('?').map(|(_, query)| query))
2965 .ok_or_else(|| {
2966 ProviderError::Auth("Google OAuth callback did not include a query".to_string())
2967 })?;
2968 let params = parse_query(query);
2969 if let Some(error) = params.get("error") {
2970 let description = params
2971 .get("error_description")
2972 .map(String::as_str)
2973 .unwrap_or("Google did not provide an error description");
2974 let message = format!("{error}: {description}");
2975 let _ = write_oauth_callback_response(&mut stream, "Google", false, &message);
2976 return Err(ProviderError::Auth(message));
2977 }
2978 let code = params.get("code").ok_or_else(|| {
2979 let message = "Google OAuth callback did not include code".to_string();
2980 let _ = write_oauth_callback_response(&mut stream, "Google", false, &message);
2981 ProviderError::Auth(message)
2982 })?;
2983 if params.get("state") != Some(&state) {
2984 let message = "Google OAuth callback state mismatch".to_string();
2985 let _ = write_oauth_callback_response(&mut stream, "Google", false, &message);
2986 return Err(ProviderError::Auth(message));
2987 }
2988 let _ = write_oauth_callback_response(
2989 &mut stream,
2990 "Google",
2991 true,
2992 "rcal Google login complete. You can close this tab.",
2993 );
2994 let mut fields = vec![
2995 ("grant_type", "authorization_code"),
2996 ("client_id", account.client_id.as_str()),
2997 ("code", code),
2998 ("redirect_uri", &redirect_uri),
2999 ("code_verifier", &verifier),
3000 ];
3001 if let Some(client_secret) = &account.client_secret {
3002 fields.push(("client_secret", client_secret.as_str()));
3003 }
3004 let response = http.request(MicrosoftHttpRequest {
3005 method: "POST".to_string(),
3006 url: GOOGLE_TOKEN_URL.to_string(),
3007 headers: vec![(
3008 "Content-Type".to_string(),
3009 "application/x-www-form-urlencoded".to_string(),
3010 )],
3011 body: Some(form_body(&fields)),
3012 })?;
3013 let token = google_token_from_response(response, None)?;
3014 token_store.save(&account.id, &token)?;
3015 writeln!(stdout, "authenticated Google account '{}'", account.id)
3016 .map_err(|err| ProviderError::Auth(err.to_string()))
3017 }
3018
3019 pub fn logout_google(
3020 account_id: &str,
3021 token_store: &dyn GoogleTokenStore,
3022 ) -> Result<(), ProviderError> {
3023 token_store.delete(account_id)
3024 }
3025
3026 #[derive(Debug, Clone, PartialEq, Eq)]
3027 pub struct GoogleTokenInspection {
3028 pub account_id: String,
3029 pub stored_expires_at_epoch_seconds: u64,
3030 pub has_refresh_token: bool,
3031 }
3032
3033 pub fn inspect_google_token(
3034 account_id: &str,
3035 token_store: &dyn GoogleTokenStore,
3036 ) -> Result<GoogleTokenInspection, ProviderError> {
3037 let token = token_store.load(account_id)?.ok_or_else(|| {
3038 ProviderError::Auth(format!(
3039 "Google account '{account_id}' is not authenticated"
3040 ))
3041 })?;
3042 Ok(GoogleTokenInspection {
3043 account_id: account_id.to_string(),
3044 stored_expires_at_epoch_seconds: token.expires_at_epoch_seconds,
3045 has_refresh_token: !token.refresh_token.is_empty(),
3046 })
3047 }
3048
3049 pub fn list_google_calendars(
3050 account: &GoogleAccountConfig,
3051 http: &dyn MicrosoftHttpClient,
3052 token_store: &dyn GoogleTokenStore,
3053 ) -> Result<Vec<GoogleCalendarInfo>, ProviderError> {
3054 let token = google_access_token(account, http, token_store)?;
3055 let response = google_request(
3056 http,
3057 "GET",
3058 &format!("{GOOGLE_CALENDAR_BASE_URL}/users/me/calendarList?maxResults=250"),
3059 &token,
3060 None,
3061 )?;
3062 let value = parse_google_success_json(response)?;
3063 let calendars = value
3064 .get("items")
3065 .and_then(Value::as_array)
3066 .ok_or_else(|| {
3067 ProviderError::Mapping("Google calendar list response is missing items".to_string())
3068 })?
3069 .iter()
3070 .map(|calendar| {
3071 let access_role = graph_string(calendar, "accessRole").unwrap_or_default();
3072 GoogleCalendarInfo {
3073 id: graph_string(calendar, "id").unwrap_or_default(),
3074 name: graph_string(calendar, "summaryOverride")
3075 .or_else(|| graph_string(calendar, "summary"))
3076 .unwrap_or_default(),
3077 can_edit: matches!(access_role.as_str(), "owner" | "writer"),
3078 is_default: graph_bool(calendar, "primary").unwrap_or(false),
3079 }
3080 })
3081 .filter(|calendar| !calendar.id.is_empty())
3082 .collect::<Vec<_>>();
3083 Ok(calendars)
3084 }
3085
3086 fn fetch_calendar(
3087 http: &dyn MicrosoftHttpClient,
3088 token: &str,
3089 calendar_id: &str,
3090 ) -> Result<MicrosoftCalendarRecord, ProviderError> {
3091 let response = graph_request(
3092 http,
3093 "GET",
3094 &format!(
3095 "{GRAPH_BASE_URL}/me/calendars/{}?$select=id,name,canEdit,isDefaultCalendar",
3096 percent_encode(calendar_id)
3097 ),
3098 token,
3099 None,
3100 )?;
3101 let value = parse_graph_success_json(response)?;
3102 Ok(MicrosoftCalendarRecord {
3103 id: graph_string(&value, "id").unwrap_or_else(|| calendar_id.to_string()),
3104 name: graph_string(&value, "name").unwrap_or_else(|| calendar_id.to_string()),
3105 can_edit: graph_bool(&value, "canEdit").unwrap_or(true),
3106 is_default: graph_bool(&value, "isDefaultCalendar").unwrap_or(false),
3107 })
3108 }
3109
3110 fn fetch_calendar_view(
3111 http: &dyn MicrosoftHttpClient,
3112 token: &str,
3113 account_id: &str,
3114 calendar: &MicrosoftCalendarRecord,
3115 start: CalendarDate,
3116 end: CalendarDate,
3117 ) -> Result<Vec<MicrosoftCachedEvent>, ProviderError> {
3118 let mut url = format!(
3119 "{GRAPH_BASE_URL}/me/calendars/{}/calendarView?startDateTime={}T00:00:00&endDateTime={}T00:00:00&$top=200",
3120 percent_encode(&calendar.id),
3121 start,
3122 end
3123 );
3124 let mut events = Vec::new();
3125 let mut series_master_ids = HashSet::new();
3126 loop {
3127 let response = graph_request(http, "GET", &url, token, None)?;
3128 let value = parse_graph_success_json(response)?;
3129 if let Some(values) = value.get("value").and_then(Value::as_array) {
3130 for event in values {
3131 if event.get("@removed").is_some() {
3132 continue;
3133 }
3134 if let Some(series_master_id) = graph_string(event, "seriesMasterId") {
3135 series_master_ids.insert(series_master_id);
3136 }
3137 events.push(MicrosoftCachedEvent::from_graph(
3138 account_id,
3139 calendar,
3140 event.clone(),
3141 )?);
3142 }
3143 }
3144 if let Some(next_link) = graph_string(&value, "@odata.nextLink") {
3145 url = next_link;
3146 } else {
3147 break;
3148 }
3149 }
3150 for series_master_id in series_master_ids {
3151 if events
3152 .iter()
3153 .any(|event| event.graph_id == series_master_id)
3154 {
3155 continue;
3156 }
3157 let raw = fetch_event(http, token, &series_master_id)?;
3158 events.push(MicrosoftCachedEvent::from_graph(account_id, calendar, raw)?);
3159 }
3160 Ok(events)
3161 }
3162
3163 fn fetch_event(
3164 http: &dyn MicrosoftHttpClient,
3165 token: &str,
3166 graph_id: &str,
3167 ) -> Result<Value, ProviderError> {
3168 let response = graph_request(
3169 http,
3170 "GET",
3171 &format!("{GRAPH_BASE_URL}/me/events/{}", percent_encode(graph_id)),
3172 token,
3173 None,
3174 )?;
3175 parse_graph_success_json(response)
3176 }
3177
3178 fn google_access_token(
3179 account: &GoogleAccountConfig,
3180 http: &dyn MicrosoftHttpClient,
3181 token_store: &dyn GoogleTokenStore,
3182 ) -> Result<String, ProviderError> {
3183 let token = token_store.load(&account.id)?.ok_or_else(|| {
3184 ProviderError::Auth(format!(
3185 "Google account '{}' is not authenticated",
3186 account.id
3187 ))
3188 })?;
3189 if token.expires_at_epoch_seconds > current_epoch_seconds().saturating_add(120) {
3190 return Ok(token.access_token);
3191 }
3192 let mut fields = vec![
3193 ("grant_type", "refresh_token"),
3194 ("client_id", account.client_id.as_str()),
3195 ("refresh_token", token.refresh_token.as_str()),
3196 ];
3197 if let Some(client_secret) = &account.client_secret {
3198 fields.push(("client_secret", client_secret.as_str()));
3199 }
3200 let response = http.request(MicrosoftHttpRequest {
3201 method: "POST".to_string(),
3202 url: GOOGLE_TOKEN_URL.to_string(),
3203 headers: vec![(
3204 "Content-Type".to_string(),
3205 "application/x-www-form-urlencoded".to_string(),
3206 )],
3207 body: Some(form_body(&fields)),
3208 })?;
3209 let refreshed = google_token_from_response(response, Some(token.refresh_token))?;
3210 token_store.save(&account.id, &refreshed)?;
3211 Ok(refreshed.access_token)
3212 }
3213
3214 fn fetch_google_calendar(
3215 http: &dyn MicrosoftHttpClient,
3216 token: &str,
3217 calendar_id: &str,
3218 ) -> Result<GoogleCalendarRecord, ProviderError> {
3219 let response = google_request(
3220 http,
3221 "GET",
3222 &format!(
3223 "{GOOGLE_CALENDAR_BASE_URL}/users/me/calendarList/{}",
3224 percent_encode(calendar_id)
3225 ),
3226 token,
3227 None,
3228 )?;
3229 let value = parse_google_success_json(response)?;
3230 let access_role = graph_string(&value, "accessRole").unwrap_or_default();
3231 Ok(GoogleCalendarRecord {
3232 id: graph_string(&value, "id").unwrap_or_else(|| calendar_id.to_string()),
3233 name: graph_string(&value, "summaryOverride")
3234 .or_else(|| graph_string(&value, "summary"))
3235 .unwrap_or_else(|| calendar_id.to_string()),
3236 can_edit: matches!(access_role.as_str(), "owner" | "writer"),
3237 is_default: graph_bool(&value, "primary").unwrap_or(false),
3238 })
3239 }
3240
3241 fn fetch_google_events(
3242 http: &dyn MicrosoftHttpClient,
3243 token: &str,
3244 account_id: &str,
3245 calendar: &GoogleCalendarRecord,
3246 start: CalendarDate,
3247 end: CalendarDate,
3248 ) -> Result<Vec<GoogleCachedEvent>, ProviderError> {
3249 let mut url = format!(
3250 "{GOOGLE_CALENDAR_BASE_URL}/calendars/{}/events?singleEvents=true&showDeleted=false&maxResults=2500&orderBy=startTime&timeMin={}T00:00:00Z&timeMax={}T00:00:00Z",
3251 percent_encode(&calendar.id),
3252 start,
3253 end
3254 );
3255 let mut events = Vec::new();
3256 let mut series_master_ids = HashSet::new();
3257 loop {
3258 let response = google_request(http, "GET", &url, token, None)?;
3259 let value = parse_google_success_json(response)?;
3260 if let Some(values) = value.get("items").and_then(Value::as_array) {
3261 for event in values {
3262 if graph_string(event, "status").as_deref() == Some("cancelled") {
3263 continue;
3264 }
3265 if let Some(series_master_id) = graph_string(event, "recurringEventId") {
3266 series_master_ids.insert(series_master_id);
3267 }
3268 events.push(GoogleCachedEvent::from_google(
3269 account_id,
3270 calendar,
3271 event.clone(),
3272 )?);
3273 }
3274 }
3275 if let Some(next_page_token) = graph_string(&value, "nextPageToken") {
3276 url = format!(
3277 "{GOOGLE_CALENDAR_BASE_URL}/calendars/{}/events?singleEvents=true&showDeleted=false&maxResults=2500&orderBy=startTime&timeMin={}T00:00:00Z&timeMax={}T00:00:00Z&pageToken={}",
3278 percent_encode(&calendar.id),
3279 start,
3280 end,
3281 percent_encode(&next_page_token)
3282 );
3283 } else {
3284 break;
3285 }
3286 }
3287 for series_master_id in series_master_ids {
3288 if events
3289 .iter()
3290 .any(|event| event.google_id == series_master_id)
3291 {
3292 continue;
3293 }
3294 let raw = fetch_google_event(http, token, &calendar.id, &series_master_id)?;
3295 events.push(GoogleCachedEvent::from_google(account_id, calendar, raw)?);
3296 }
3297 Ok(events)
3298 }
3299
3300 fn fetch_google_event(
3301 http: &dyn MicrosoftHttpClient,
3302 token: &str,
3303 calendar_id: &str,
3304 google_id: &str,
3305 ) -> Result<Value, ProviderError> {
3306 let response = google_request(
3307 http,
3308 "GET",
3309 &format!(
3310 "{GOOGLE_CALENDAR_BASE_URL}/calendars/{}/events/{}",
3311 percent_encode(calendar_id),
3312 percent_encode(google_id)
3313 ),
3314 token,
3315 None,
3316 )?;
3317 parse_google_success_json(response)
3318 }
3319
3320 fn graph_request(
3321 http: &dyn MicrosoftHttpClient,
3322 method: &str,
3323 url: &str,
3324 token: &str,
3325 body: Option<String>,
3326 ) -> Result<MicrosoftHttpResponse, ProviderError> {
3327 let mut headers = vec![
3328 ("Authorization".to_string(), format!("Bearer {token}")),
3329 ("Accept".to_string(), "application/json".to_string()),
3330 ("Prefer".to_string(), "outlook.timezone=\"UTC\"".to_string()),
3331 ];
3332 if body.is_some() {
3333 headers.push(("Content-Type".to_string(), "application/json".to_string()));
3334 }
3335 http.request(MicrosoftHttpRequest {
3336 method: method.to_string(),
3337 url: url.to_string(),
3338 headers,
3339 body,
3340 })
3341 }
3342
3343 fn parse_graph_success_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
3344 if (200..300).contains(&response.status) {
3345 return serde_json::from_str(&response.body)
3346 .map_err(|err| ProviderError::Mapping(err.to_string()));
3347 }
3348 Err(ProviderError::Graph(graph_error_message(response)))
3349 }
3350
3351 fn parse_graph_empty_success(response: MicrosoftHttpResponse) -> Result<(), ProviderError> {
3352 if (200..300).contains(&response.status) {
3353 Ok(())
3354 } else {
3355 Err(ProviderError::Graph(graph_error_message(response)))
3356 }
3357 }
3358
3359 fn google_request(
3360 http: &dyn MicrosoftHttpClient,
3361 method: &str,
3362 url: &str,
3363 token: &str,
3364 body: Option<String>,
3365 ) -> Result<MicrosoftHttpResponse, ProviderError> {
3366 let mut headers = vec![
3367 ("Authorization".to_string(), format!("Bearer {token}")),
3368 ("Accept".to_string(), "application/json".to_string()),
3369 ];
3370 if body.is_some() {
3371 headers.push(("Content-Type".to_string(), "application/json".to_string()));
3372 }
3373 http.request(MicrosoftHttpRequest {
3374 method: method.to_string(),
3375 url: url.to_string(),
3376 headers,
3377 body,
3378 })
3379 }
3380
3381 fn parse_google_success_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
3382 if (200..300).contains(&response.status) {
3383 return serde_json::from_str(&response.body)
3384 .map_err(|err| ProviderError::Mapping(format!("Google: {err}")));
3385 }
3386 Err(ProviderError::Api(google_error_message(response)))
3387 }
3388
3389 fn parse_google_empty_success(response: MicrosoftHttpResponse) -> Result<(), ProviderError> {
3390 if (200..300).contains(&response.status) {
3391 Ok(())
3392 } else {
3393 Err(ProviderError::Api(google_error_message(response)))
3394 }
3395 }
3396
3397 fn graph_error_message(response: MicrosoftHttpResponse) -> String {
3398 let www_authenticate = response
3399 .headers
3400 .iter()
3401 .find(|(name, _)| name.eq_ignore_ascii_case("www-authenticate"))
3402 .map(|(_, value)| value.as_str());
3403 if let Ok(value) = serde_json::from_str::<Value>(&response.body)
3404 && let Some(error) = value.get("error")
3405 {
3406 let code = graph_string(error, "code").unwrap_or_else(|| response.status.to_string());
3407 let message = graph_string(error, "message").unwrap_or_else(|| response.body.clone());
3408 return match www_authenticate {
3409 Some(header) if !header.is_empty() => format!("{code}: {message} ({header})"),
3410 _ => format!("{code}: {message}"),
3411 };
3412 }
3413 match (response.body.trim(), www_authenticate) {
3414 ("", Some(header)) if !header.is_empty() => format!("HTTP {}: {header}", response.status),
3415 (body, Some(header)) if !header.is_empty() => {
3416 format!("HTTP {}: {body} ({header})", response.status)
3417 }
3418 (body, _) => format!("HTTP {}: {body}", response.status),
3419 }
3420 }
3421
3422 fn google_error_message(response: MicrosoftHttpResponse) -> String {
3423 if let Ok(value) = serde_json::from_str::<Value>(&response.body)
3424 && let Some(error) = value.get("error")
3425 {
3426 let code = graph_string(error, "status")
3427 .or_else(|| graph_string(error, "code"))
3428 .unwrap_or_else(|| response.status.to_string());
3429 let message = graph_string(error, "message").unwrap_or_else(|| response.body.clone());
3430 return format!("Google Calendar {code}: {message}");
3431 }
3432 let body = response.body.trim();
3433 if body.is_empty() {
3434 format!("Google Calendar HTTP {}", response.status)
3435 } else {
3436 format!("Google Calendar HTTP {}: {body}", response.status)
3437 }
3438 }
3439
3440 fn google_oauth_error_message(response: MicrosoftHttpResponse) -> String {
3441 if let Ok(value) = serde_json::from_str::<Value>(&response.body)
3442 && let Some(error) = graph_string(&value, "error")
3443 {
3444 let description = graph_string(&value, "error_description");
3445 return match description {
3446 Some(description) if !description.is_empty() => {
3447 format!("Google OAuth {}: {error}: {description}", response.status)
3448 }
3449 _ => format!("Google OAuth {}: {error}", response.status),
3450 };
3451 }
3452 let body = response.body.trim();
3453 if body.is_empty() {
3454 format!("Google OAuth HTTP {}", response.status)
3455 } else {
3456 format!("Google OAuth HTTP {}: {body}", response.status)
3457 }
3458 }
3459
3460 fn parse_oauth_json(response: MicrosoftHttpResponse) -> Result<Value, ProviderError> {
3461 if response.status != 200 {
3462 return Err(ProviderError::Auth(graph_error_message(response)));
3463 }
3464 serde_json::from_str::<Value>(&response.body)
3465 .map_err(|err| ProviderError::Auth(err.to_string()))
3466 }
3467
3468 fn token_from_response(response: MicrosoftHttpResponse) -> Result<MicrosoftToken, ProviderError> {
3469 let value = parse_oauth_json(response)?;
3470 let access_token = required_json_string(&value, "access_token")?;
3471 let refresh_token = graph_string(&value, "refresh_token").unwrap_or_default();
3472 let expires_in = graph_i64(&value, "expires_in").unwrap_or(3600).max(1) as u64;
3473 Ok(MicrosoftToken {
3474 access_token,
3475 refresh_token,
3476 expires_at_epoch_seconds: current_epoch_seconds().saturating_add(expires_in),
3477 })
3478 }
3479
3480 fn google_token_from_response(
3481 response: MicrosoftHttpResponse,
3482 existing_refresh_token: Option<String>,
3483 ) -> Result<GoogleToken, ProviderError> {
3484 if response.status != 200 {
3485 return Err(ProviderError::Auth(google_oauth_error_message(response)));
3486 }
3487 let value = serde_json::from_str::<Value>(&response.body)
3488 .map_err(|err| ProviderError::Auth(format!("Google token response is invalid: {err}")))?;
3489 let access_token = required_json_string(&value, "access_token")?;
3490 let refresh_token = graph_string(&value, "refresh_token")
3491 .or(existing_refresh_token)
3492 .unwrap_or_default();
3493 let expires_in = graph_i64(&value, "expires_in").unwrap_or(3600).max(1) as u64;
3494 Ok(GoogleToken {
3495 access_token,
3496 refresh_token,
3497 expires_at_epoch_seconds: current_epoch_seconds().saturating_add(expires_in),
3498 })
3499 }
3500
3501 fn access_token_claims(access_token: &str) -> Result<Option<Value>, ProviderError> {
3502 let Some(payload) = access_token.split('.').nth(1) else {
3503 return Ok(None);
3504 };
3505 let bytes = base64_url_decode_no_pad(payload).ok_or_else(|| {
3506 ProviderError::Auth("stored Microsoft access token has invalid JWT encoding".to_string())
3507 })?;
3508 serde_json::from_slice(&bytes)
3509 .map_err(|err| {
3510 ProviderError::Auth(format!("stored Microsoft access token is invalid: {err}"))
3511 })
3512 .map(Some)
3513 }
3514
3515 fn required_json_string(value: &Value, key: &str) -> Result<String, ProviderError> {
3516 graph_string(value, key)
3517 .ok_or_else(|| ProviderError::Auth(format!("OAuth response is missing {key}")))
3518 }
3519
3520 pub fn graph_event_payload(
3521 draft: &CreateEventDraft,
3522 is_update: bool,
3523 ) -> Result<Value, ProviderError> {
3524 if draft.reminders.len() > 1 {
3525 return Err(ProviderError::Validation(
3526 "Microsoft events support only one reminder".to_string(),
3527 ));
3528 }
3529 let mut payload = Map::new();
3530 payload.insert("subject".to_string(), Value::String(draft.title.clone()));
3531 if let Some(notes) = &draft.notes {
3532 payload.insert(
3533 "body".to_string(),
3534 json!({
3535 "contentType": "text",
3536 "content": notes
3537 }),
3538 );
3539 }
3540 if let Some(location) = &draft.location {
3541 payload.insert("location".to_string(), json!({ "displayName": location }));
3542 }
3543 if let Some(reminder) = draft.reminders.first() {
3544 payload.insert("isReminderOn".to_string(), Value::Bool(true));
3545 payload.insert(
3546 "reminderMinutesBeforeStart".to_string(),
3547 Value::Number(serde_json::Number::from(reminder.minutes_before)),
3548 );
3549 } else if !is_update {
3550 payload.insert("isReminderOn".to_string(), Value::Bool(false));
3551 }
3552 match draft.timing {
3553 CreateEventTiming::AllDay { date } => {
3554 payload.insert("isAllDay".to_string(), Value::Bool(true));
3555 payload.insert(
3556 "start".to_string(),
3557 graph_datetime_payload(date, Time::MIDNIGHT),
3558 );
3559 payload.insert(
3560 "end".to_string(),
3561 graph_datetime_payload(date.add_days(1), Time::MIDNIGHT),
3562 );
3563 }
3564 CreateEventTiming::Timed { start, end } => {
3565 payload.insert("isAllDay".to_string(), Value::Bool(false));
3566 payload.insert(
3567 "start".to_string(),
3568 graph_datetime_payload(start.date, start.time),
3569 );
3570 payload.insert(
3571 "end".to_string(),
3572 graph_datetime_payload(end.date, end.time),
3573 );
3574 }
3575 }
3576 if let Some(recurrence) = &draft.recurrence {
3577 payload.insert(
3578 "recurrence".to_string(),
3579 graph_recurrence_payload(recurrence, draft)?,
3580 );
3581 }
3582 Ok(Value::Object(payload))
3583 }
3584
3585 pub fn google_event_payload(
3586 draft: &CreateEventDraft,
3587 is_update: bool,
3588 ) -> Result<Value, ProviderError> {
3589 if draft.reminders.len() > 5 {
3590 return Err(ProviderError::Validation(
3591 "Google Calendar events support at most five reminders".to_string(),
3592 ));
3593 }
3594 let mut payload = Map::new();
3595 payload.insert("summary".to_string(), Value::String(draft.title.clone()));
3596 if let Some(notes) = &draft.notes {
3597 payload.insert("description".to_string(), Value::String(notes.clone()));
3598 } else if is_update {
3599 payload.insert("description".to_string(), Value::String(String::new()));
3600 }
3601 if let Some(location) = &draft.location {
3602 payload.insert("location".to_string(), Value::String(location.clone()));
3603 } else if is_update {
3604 payload.insert("location".to_string(), Value::String(String::new()));
3605 }
3606 if draft.reminders.is_empty() {
3607 payload.insert("reminders".to_string(), json!({ "useDefault": false }));
3608 } else {
3609 payload.insert(
3610 "reminders".to_string(),
3611 json!({
3612 "useDefault": false,
3613 "overrides": draft.reminders.iter().map(|reminder| {
3614 json!({
3615 "method": "popup",
3616 "minutes": reminder.minutes_before
3617 })
3618 }).collect::<Vec<_>>()
3619 }),
3620 );
3621 }
3622 match draft.timing {
3623 CreateEventTiming::AllDay { date } => {
3624 payload.insert("start".to_string(), json!({ "date": date.to_string() }));
3625 payload.insert(
3626 "end".to_string(),
3627 json!({ "date": date.add_days(1).to_string() }),
3628 );
3629 }
3630 CreateEventTiming::Timed { start, end } => {
3631 payload.insert("start".to_string(), google_datetime_payload(start));
3632 payload.insert("end".to_string(), google_datetime_payload(end));
3633 }
3634 }
3635 if let Some(recurrence) = &draft.recurrence {
3636 payload.insert(
3637 "recurrence".to_string(),
3638 Value::Array(vec![Value::String(google_rrule_payload(recurrence, draft))]),
3639 );
3640 }
3641 Ok(Value::Object(payload))
3642 }
3643
3644 fn graph_datetime_payload(date: CalendarDate, time: Time) -> Value {
3645 json!({
3646 "dateTime": format!("{}T{:02}:{:02}:00", date, time.hour(), time.minute()),
3647 "timeZone": "UTC"
3648 })
3649 }
3650
3651 fn google_datetime_payload(value: EventDateTime) -> Value {
3652 json!({
3653 "dateTime": format!(
3654 "{}T{:02}:{:02}:00Z",
3655 value.date,
3656 value.time.hour(),
3657 value.time.minute()
3658 )
3659 })
3660 }
3661
3662 fn graph_recurrence_payload(
3663 rule: &RecurrenceRule,
3664 draft: &CreateEventDraft,
3665 ) -> Result<Value, ProviderError> {
3666 let start_date = match draft.timing {
3667 CreateEventTiming::AllDay { date } => date,
3668 CreateEventTiming::Timed { start, .. } => start.date,
3669 };
3670 let mut pattern = Map::new();
3671 pattern.insert(
3672 "interval".to_string(),
3673 Value::Number(serde_json::Number::from(rule.interval().max(1))),
3674 );
3675 match rule.frequency {
3676 RecurrenceFrequency::Daily => {
3677 pattern.insert("type".to_string(), Value::String("daily".to_string()));
3678 }
3679 RecurrenceFrequency::Weekly => {
3680 pattern.insert("type".to_string(), Value::String("weekly".to_string()));
3681 pattern.insert(
3682 "daysOfWeek".to_string(),
3683 Value::Array(
3684 rule.weekdays
3685 .iter()
3686 .copied()
3687 .map(graph_weekday)
3688 .map(Value::String)
3689 .collect(),
3690 ),
3691 );
3692 pattern.insert(
3693 "firstDayOfWeek".to_string(),
3694 Value::String("sunday".to_string()),
3695 );
3696 }
3697 RecurrenceFrequency::Monthly => match rule.monthly {
3698 Some(RecurrenceMonthlyRule::DayOfMonth(day)) => {
3699 pattern.insert(
3700 "type".to_string(),
3701 Value::String("absoluteMonthly".to_string()),
3702 );
3703 pattern.insert(
3704 "dayOfMonth".to_string(),
3705 Value::Number(serde_json::Number::from(day)),
3706 );
3707 }
3708 Some(RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday }) => {
3709 pattern.insert(
3710 "type".to_string(),
3711 Value::String("relativeMonthly".to_string()),
3712 );
3713 pattern.insert("index".to_string(), Value::String(graph_ordinal(ordinal)));
3714 pattern.insert(
3715 "daysOfWeek".to_string(),
3716 Value::Array(vec![Value::String(graph_weekday(weekday))]),
3717 );
3718 }
3719 None => {
3720 pattern.insert(
3721 "type".to_string(),
3722 Value::String("absoluteMonthly".to_string()),
3723 );
3724 pattern.insert(
3725 "dayOfMonth".to_string(),
3726 Value::Number(serde_json::Number::from(start_date.day())),
3727 );
3728 }
3729 },
3730 RecurrenceFrequency::Yearly => match rule.yearly {
3731 Some(RecurrenceYearlyRule::Date { month, day }) => {
3732 pattern.insert(
3733 "type".to_string(),
3734 Value::String("absoluteYearly".to_string()),
3735 );
3736 pattern.insert(
3737 "month".to_string(),
3738 Value::Number(serde_json::Number::from(u8::from(month))),
3739 );
3740 pattern.insert(
3741 "dayOfMonth".to_string(),
3742 Value::Number(serde_json::Number::from(day)),
3743 );
3744 }
3745 Some(RecurrenceYearlyRule::WeekdayOrdinal {
3746 month,
3747 ordinal,
3748 weekday,
3749 }) => {
3750 pattern.insert(
3751 "type".to_string(),
3752 Value::String("relativeYearly".to_string()),
3753 );
3754 pattern.insert(
3755 "month".to_string(),
3756 Value::Number(serde_json::Number::from(u8::from(month))),
3757 );
3758 pattern.insert("index".to_string(), Value::String(graph_ordinal(ordinal)));
3759 pattern.insert(
3760 "daysOfWeek".to_string(),
3761 Value::Array(vec![Value::String(graph_weekday(weekday))]),
3762 );
3763 }
3764 None => {
3765 pattern.insert(
3766 "type".to_string(),
3767 Value::String("absoluteYearly".to_string()),
3768 );
3769 pattern.insert(
3770 "month".to_string(),
3771 Value::Number(serde_json::Number::from(u8::from(start_date.month()))),
3772 );
3773 pattern.insert(
3774 "dayOfMonth".to_string(),
3775 Value::Number(serde_json::Number::from(start_date.day())),
3776 );
3777 }
3778 },
3779 }
3780 let range = match rule.end {
3781 RecurrenceEnd::Never => json!({
3782 "type": "noEnd",
3783 "startDate": start_date.to_string(),
3784 "recurrenceTimeZone": "UTC"
3785 }),
3786 RecurrenceEnd::Until(date) => json!({
3787 "type": "endDate",
3788 "startDate": start_date.to_string(),
3789 "endDate": date.to_string(),
3790 "recurrenceTimeZone": "UTC"
3791 }),
3792 RecurrenceEnd::Count(count) => json!({
3793 "type": "numbered",
3794 "startDate": start_date.to_string(),
3795 "numberOfOccurrences": count,
3796 "recurrenceTimeZone": "UTC"
3797 }),
3798 };
3799 Ok(json!({
3800 "pattern": Value::Object(pattern),
3801 "range": range
3802 }))
3803 }
3804
3805 fn google_rrule_payload(rule: &RecurrenceRule, draft: &CreateEventDraft) -> String {
3806 let start_date = match draft.timing {
3807 CreateEventTiming::AllDay { date } => date,
3808 CreateEventTiming::Timed { start, .. } => start.date,
3809 };
3810 let mut parts = vec![format!(
3811 "FREQ={}",
3812 match rule.frequency {
3813 RecurrenceFrequency::Daily => "DAILY",
3814 RecurrenceFrequency::Weekly => "WEEKLY",
3815 RecurrenceFrequency::Monthly => "MONTHLY",
3816 RecurrenceFrequency::Yearly => "YEARLY",
3817 }
3818 )];
3819 if rule.interval() > 1 {
3820 parts.push(format!("INTERVAL={}", rule.interval()));
3821 }
3822 match rule.frequency {
3823 RecurrenceFrequency::Weekly => {
3824 if !rule.weekdays.is_empty() {
3825 parts.push(format!(
3826 "BYDAY={}",
3827 rule.weekdays
3828 .iter()
3829 .copied()
3830 .map(google_weekday)
3831 .collect::<Vec<_>>()
3832 .join(",")
3833 ));
3834 }
3835 }
3836 RecurrenceFrequency::Monthly => match rule.monthly {
3837 Some(RecurrenceMonthlyRule::DayOfMonth(day)) => {
3838 parts.push(format!("BYMONTHDAY={day}"));
3839 }
3840 Some(RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday }) => {
3841 parts.push(format!(
3842 "BYDAY={}{}",
3843 google_ordinal_prefix(ordinal),
3844 google_weekday(weekday)
3845 ));
3846 }
3847 None => parts.push(format!("BYMONTHDAY={}", start_date.day())),
3848 },
3849 RecurrenceFrequency::Yearly => match rule.yearly {
3850 Some(RecurrenceYearlyRule::Date { month, day }) => {
3851 parts.push(format!("BYMONTH={}", u8::from(month)));
3852 parts.push(format!("BYMONTHDAY={day}"));
3853 }
3854 Some(RecurrenceYearlyRule::WeekdayOrdinal {
3855 month,
3856 ordinal,
3857 weekday,
3858 }) => {
3859 parts.push(format!("BYMONTH={}", u8::from(month)));
3860 parts.push(format!(
3861 "BYDAY={}{}",
3862 google_ordinal_prefix(ordinal),
3863 google_weekday(weekday)
3864 ));
3865 }
3866 None => {
3867 parts.push(format!("BYMONTH={}", u8::from(start_date.month())));
3868 parts.push(format!("BYMONTHDAY={}", start_date.day()));
3869 }
3870 },
3871 RecurrenceFrequency::Daily => {}
3872 }
3873 match rule.end {
3874 RecurrenceEnd::Never => {}
3875 RecurrenceEnd::Until(date) => {
3876 parts.push(format!(
3877 "UNTIL={}T000000Z",
3878 date.to_string().replace('-', "")
3879 ));
3880 }
3881 RecurrenceEnd::Count(count) => {
3882 parts.push(format!("COUNT={count}"));
3883 }
3884 }
3885 format!("RRULE:{}", parts.join(";"))
3886 }
3887
3888 fn graph_recurrence_to_cache(value: &Value) -> Result<MicrosoftCachedRecurrence, ProviderError> {
3889 let pattern = value
3890 .get("pattern")
3891 .ok_or_else(|| ProviderError::Mapping("recurrence is missing pattern".to_string()))?;
3892 let range = value
3893 .get("range")
3894 .ok_or_else(|| ProviderError::Mapping("recurrence is missing range".to_string()))?;
3895 let interval = graph_i64(pattern, "interval")
3896 .and_then(|value| u16::try_from(value).ok())
3897 .unwrap_or(1)
3898 .max(1);
3899 let end = match graph_string(range, "type").as_deref() {
3900 Some("noEnd") => RecurrenceEndRecord::Never,
3901 Some("endDate") => RecurrenceEndRecord::Until {
3902 date: graph_string(range, "endDate")
3903 .and_then(|value| parse_date(&value))
3904 .ok_or_else(|| {
3905 ProviderError::Mapping("recurrence endDate is invalid".to_string())
3906 })?,
3907 },
3908 Some("numbered") => RecurrenceEndRecord::Count {
3909 count: graph_i64(range, "numberOfOccurrences")
3910 .and_then(|value| u32::try_from(value).ok())
3911 .unwrap_or(1),
3912 },
3913 _ => RecurrenceEndRecord::Never,
3914 };
3915 let weekdays = pattern
3916 .get("daysOfWeek")
3917 .and_then(Value::as_array)
3918 .into_iter()
3919 .flatten()
3920 .filter_map(Value::as_str)
3921 .filter_map(parse_graph_weekday)
3922 .map(WeekdayRecord::from_weekday)
3923 .collect::<Vec<_>>();
3924 match graph_string(pattern, "type").as_deref() {
3925 Some("daily") => Ok(MicrosoftCachedRecurrence {
3926 frequency: RecurrenceFrequencyRecord::Daily,
3927 interval,
3928 end,
3929 weekdays: Vec::new(),
3930 monthly: None,
3931 yearly: None,
3932 }),
3933 Some("weekly") => Ok(MicrosoftCachedRecurrence {
3934 frequency: RecurrenceFrequencyRecord::Weekly,
3935 interval,
3936 end,
3937 weekdays,
3938 monthly: None,
3939 yearly: None,
3940 }),
3941 Some("absoluteMonthly") => Ok(MicrosoftCachedRecurrence {
3942 frequency: RecurrenceFrequencyRecord::Monthly,
3943 interval,
3944 end,
3945 weekdays: Vec::new(),
3946 monthly: Some(MonthlyRuleRecord::DayOfMonth {
3947 day: graph_i64(pattern, "dayOfMonth")
3948 .and_then(|value| u8::try_from(value).ok())
3949 .unwrap_or(1),
3950 }),
3951 yearly: None,
3952 }),
3953 Some("relativeMonthly") => Ok(MicrosoftCachedRecurrence {
3954 frequency: RecurrenceFrequencyRecord::Monthly,
3955 interval,
3956 end,
3957 weekdays: Vec::new(),
3958 monthly: Some(MonthlyRuleRecord::WeekdayOrdinal {
3959 ordinal: parse_graph_ordinal(&graph_string(pattern, "index").unwrap_or_default()),
3960 weekday: weekdays.first().copied().unwrap_or(WeekdayRecord::Monday),
3961 }),
3962 yearly: None,
3963 }),
3964 Some("absoluteYearly") => Ok(MicrosoftCachedRecurrence {
3965 frequency: RecurrenceFrequencyRecord::Yearly,
3966 interval,
3967 end,
3968 weekdays: Vec::new(),
3969 monthly: None,
3970 yearly: Some(YearlyRuleRecord::Date {
3971 month: graph_i64(pattern, "month")
3972 .and_then(|value| u8::try_from(value).ok())
3973 .unwrap_or(1),
3974 day: graph_i64(pattern, "dayOfMonth")
3975 .and_then(|value| u8::try_from(value).ok())
3976 .unwrap_or(1),
3977 }),
3978 }),
3979 Some("relativeYearly") => Ok(MicrosoftCachedRecurrence {
3980 frequency: RecurrenceFrequencyRecord::Yearly,
3981 interval,
3982 end,
3983 weekdays: Vec::new(),
3984 monthly: None,
3985 yearly: Some(YearlyRuleRecord::WeekdayOrdinal {
3986 month: graph_i64(pattern, "month")
3987 .and_then(|value| u8::try_from(value).ok())
3988 .unwrap_or(1),
3989 ordinal: parse_graph_ordinal(&graph_string(pattern, "index").unwrap_or_default()),
3990 weekday: weekdays.first().copied().unwrap_or(WeekdayRecord::Monday),
3991 }),
3992 }),
3993 other => Err(ProviderError::Mapping(format!(
3994 "unsupported Microsoft recurrence type '{}'",
3995 other.unwrap_or("<missing>")
3996 ))),
3997 }
3998 }
3999
4000 fn google_rrule_to_cache(rule: &str) -> Option<MicrosoftCachedRecurrence> {
4001 let body = rule.strip_prefix("RRULE:")?;
4002 let mut parts = HashMap::new();
4003 for part in body.split(';') {
4004 let (key, value) = part.split_once('=')?;
4005 parts.insert(key, value);
4006 }
4007 let frequency = match *parts.get("FREQ")? {
4008 "DAILY" => RecurrenceFrequencyRecord::Daily,
4009 "WEEKLY" => RecurrenceFrequencyRecord::Weekly,
4010 "MONTHLY" => RecurrenceFrequencyRecord::Monthly,
4011 "YEARLY" => RecurrenceFrequencyRecord::Yearly,
4012 _ => return None,
4013 };
4014 let interval = parts
4015 .get("INTERVAL")
4016 .and_then(|value| value.parse::<u16>().ok())
4017 .unwrap_or(1)
4018 .max(1);
4019 let end = if let Some(count) = parts
4020 .get("COUNT")
4021 .and_then(|value| value.parse::<u32>().ok())
4022 {
4023 RecurrenceEndRecord::Count { count }
4024 } else if let Some(until) = parts.get("UNTIL") {
4025 let date = until
4026 .get(0..8)
4027 .and_then(|value| {
4028 let year = value.get(0..4)?;
4029 let month = value.get(4..6)?;
4030 let day = value.get(6..8)?;
4031 parse_date(&format!("{year}-{month}-{day}"))
4032 })
4033 .unwrap_or_else(|| CalendarDate::from_ymd(9999, Month::December, 31).expect("date"));
4034 RecurrenceEndRecord::Until { date }
4035 } else {
4036 RecurrenceEndRecord::Never
4037 };
4038 let weekdays = parts
4039 .get("BYDAY")
4040 .map(|value| {
4041 value
4042 .split(',')
4043 .filter_map(|day| {
4044 parse_google_weekday(day.trim_start_matches([
4045 '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
4046 ]))
4047 })
4048 .map(WeekdayRecord::from_weekday)
4049 .collect::<Vec<_>>()
4050 })
4051 .unwrap_or_default();
4052 let monthly = if matches!(frequency, RecurrenceFrequencyRecord::Monthly) {
4053 if let Some(day) = parts
4054 .get("BYMONTHDAY")
4055 .and_then(|value| value.parse::<u8>().ok())
4056 {
4057 Some(MonthlyRuleRecord::DayOfMonth { day })
4058 } else if let Some(day) = parts.get("BYDAY").and_then(|value| value.split(',').next()) {
4059 google_ordinal_weekday(day)
4060 .map(|(ordinal, weekday)| MonthlyRuleRecord::WeekdayOrdinal { ordinal, weekday })
4061 } else {
4062 None
4063 }
4064 } else {
4065 None
4066 };
4067 let yearly = if matches!(frequency, RecurrenceFrequencyRecord::Yearly) {
4068 let month = parts
4069 .get("BYMONTH")
4070 .and_then(|value| value.parse::<u8>().ok())
4071 .unwrap_or(1);
4072 if let Some(day) = parts
4073 .get("BYMONTHDAY")
4074 .and_then(|value| value.parse::<u8>().ok())
4075 {
4076 Some(YearlyRuleRecord::Date { month, day })
4077 } else if let Some(day) = parts.get("BYDAY").and_then(|value| value.split(',').next()) {
4078 google_ordinal_weekday(day).map(|(ordinal, weekday)| YearlyRuleRecord::WeekdayOrdinal {
4079 month,
4080 ordinal,
4081 weekday,
4082 })
4083 } else {
4084 None
4085 }
4086 } else {
4087 None
4088 };
4089 Some(MicrosoftCachedRecurrence {
4090 frequency,
4091 interval,
4092 end,
4093 weekdays: if matches!(frequency, RecurrenceFrequencyRecord::Weekly) {
4094 weekdays
4095 } else {
4096 Vec::new()
4097 },
4098 monthly,
4099 yearly,
4100 })
4101 }
4102
4103 fn google_ordinal_weekday(value: &str) -> Option<(OrdinalRecord, WeekdayRecord)> {
4104 let weekday = value.get(value.len().saturating_sub(2)..)?;
4105 let ordinal = value.get(..value.len().saturating_sub(2)).unwrap_or("1");
4106 let ordinal = match ordinal {
4107 "1" | "" => OrdinalRecord::First,
4108 "2" => OrdinalRecord::Second,
4109 "3" => OrdinalRecord::Third,
4110 "4" => OrdinalRecord::Fourth,
4111 "-1" => OrdinalRecord::Last,
4112 _ => OrdinalRecord::First,
4113 };
4114 Some((
4115 ordinal,
4116 WeekdayRecord::from_weekday(parse_google_weekday(weekday)?),
4117 ))
4118 }
4119
4120 fn graph_weekday(weekday: Weekday) -> String {
4121 match weekday {
4122 Weekday::Sunday => "sunday",
4123 Weekday::Monday => "monday",
4124 Weekday::Tuesday => "tuesday",
4125 Weekday::Wednesday => "wednesday",
4126 Weekday::Thursday => "thursday",
4127 Weekday::Friday => "friday",
4128 Weekday::Saturday => "saturday",
4129 }
4130 .to_string()
4131 }
4132
4133 fn parse_graph_weekday(value: &str) -> Option<Weekday> {
4134 match value {
4135 "sunday" => Some(Weekday::Sunday),
4136 "monday" => Some(Weekday::Monday),
4137 "tuesday" => Some(Weekday::Tuesday),
4138 "wednesday" => Some(Weekday::Wednesday),
4139 "thursday" => Some(Weekday::Thursday),
4140 "friday" => Some(Weekday::Friday),
4141 "saturday" => Some(Weekday::Saturday),
4142 _ => None,
4143 }
4144 }
4145
4146 fn google_weekday(weekday: Weekday) -> &'static str {
4147 match weekday {
4148 Weekday::Sunday => "SU",
4149 Weekday::Monday => "MO",
4150 Weekday::Tuesday => "TU",
4151 Weekday::Wednesday => "WE",
4152 Weekday::Thursday => "TH",
4153 Weekday::Friday => "FR",
4154 Weekday::Saturday => "SA",
4155 }
4156 }
4157
4158 fn parse_google_weekday(value: &str) -> Option<Weekday> {
4159 match value {
4160 "SU" => Some(Weekday::Sunday),
4161 "MO" => Some(Weekday::Monday),
4162 "TU" => Some(Weekday::Tuesday),
4163 "WE" => Some(Weekday::Wednesday),
4164 "TH" => Some(Weekday::Thursday),
4165 "FR" => Some(Weekday::Friday),
4166 "SA" => Some(Weekday::Saturday),
4167 _ => None,
4168 }
4169 }
4170
4171 fn graph_ordinal(ordinal: RecurrenceOrdinal) -> String {
4172 match OrdinalRecord::from_rule(ordinal) {
4173 OrdinalRecord::First => "first",
4174 OrdinalRecord::Second => "second",
4175 OrdinalRecord::Third => "third",
4176 OrdinalRecord::Fourth => "fourth",
4177 OrdinalRecord::Last => "last",
4178 }
4179 .to_string()
4180 }
4181
4182 fn google_ordinal_prefix(ordinal: RecurrenceOrdinal) -> &'static str {
4183 match ordinal {
4184 RecurrenceOrdinal::Number(1) => "1",
4185 RecurrenceOrdinal::Number(2) => "2",
4186 RecurrenceOrdinal::Number(3) => "3",
4187 RecurrenceOrdinal::Number(_) => "4",
4188 RecurrenceOrdinal::Last => "-1",
4189 }
4190 }
4191
4192 fn parse_graph_ordinal(value: &str) -> OrdinalRecord {
4193 match value {
4194 "first" => OrdinalRecord::First,
4195 "second" => OrdinalRecord::Second,
4196 "third" => OrdinalRecord::Third,
4197 "fourth" => OrdinalRecord::Fourth,
4198 "last" => OrdinalRecord::Last,
4199 _ => OrdinalRecord::First,
4200 }
4201 }
4202
4203 fn graph_datetime(value: &Value, key: &str) -> Result<EventDateTime, ProviderError> {
4204 let date_time = value
4205 .get(key)
4206 .and_then(|date_time| graph_string(date_time, "dateTime"))
4207 .ok_or_else(|| ProviderError::Mapping(format!("Graph event is missing {key}.dateTime")))?;
4208 parse_event_datetime(&date_time)
4209 .ok_or_else(|| ProviderError::Mapping(format!("invalid Graph dateTime '{date_time}'")))
4210 }
4211
4212 fn google_timing(
4213 value: &Value,
4214 start_key: &str,
4215 end_key: &str,
4216 ) -> Result<GoogleCachedTiming, ProviderError> {
4217 let start = value
4218 .get(start_key)
4219 .ok_or_else(|| ProviderError::Mapping(format!("Google event is missing {start_key}")))?;
4220 let end = value
4221 .get(end_key)
4222 .ok_or_else(|| ProviderError::Mapping(format!("Google event is missing {end_key}")))?;
4223 match (graph_string(start, "date"), graph_string(end, "date")) {
4224 (Some(date), _) => Ok(GoogleCachedTiming::AllDay {
4225 date: parse_date(&date).ok_or_else(|| {
4226 ProviderError::Mapping(format!("invalid Google all-day date '{date}'"))
4227 })?,
4228 }),
4229 _ => {
4230 let start = graph_string(start, "dateTime").ok_or_else(|| {
4231 ProviderError::Mapping(format!("Google event is missing {start_key}.dateTime"))
4232 })?;
4233 let end = graph_string(end, "dateTime").ok_or_else(|| {
4234 ProviderError::Mapping(format!("Google event is missing {end_key}.dateTime"))
4235 })?;
4236 Ok(GoogleCachedTiming::Timed {
4237 start: parse_event_datetime(&start).ok_or_else(|| {
4238 ProviderError::Mapping(format!("invalid Google dateTime '{start}'"))
4239 })?,
4240 end: parse_event_datetime(&end).ok_or_else(|| {
4241 ProviderError::Mapping(format!("invalid Google dateTime '{end}'"))
4242 })?,
4243 })
4244 }
4245 }
4246 }
4247
4248 fn google_single_time(value: &Value) -> Result<GoogleCachedTiming, ProviderError> {
4249 if let Some(date) = graph_string(value, "date") {
4250 return Ok(GoogleCachedTiming::AllDay {
4251 date: parse_date(&date).ok_or_else(|| {
4252 ProviderError::Mapping(format!("invalid Google originalStartTime date '{date}'"))
4253 })?,
4254 });
4255 }
4256 let start = graph_string(value, "dateTime").ok_or_else(|| {
4257 ProviderError::Mapping("Google originalStartTime is missing dateTime".to_string())
4258 })?;
4259 let start = parse_event_datetime(&start).ok_or_else(|| {
4260 ProviderError::Mapping(format!(
4261 "invalid Google originalStartTime dateTime '{start}'"
4262 ))
4263 })?;
4264 Ok(GoogleCachedTiming::Timed { start, end: start })
4265 }
4266
4267 fn parse_event_datetime(value: &str) -> Option<EventDateTime> {
4268 let (date, rest) = value.split_once('T')?;
4269 let date = parse_date(date)?;
4270 let time_part = rest.split(['.', 'Z', '+', '-']).next()?;
4271 let time = parse_time(time_part)?;
4272 Some(EventDateTime::new(date, time))
4273 }
4274
4275 fn parse_date(value: &str) -> Option<CalendarDate> {
4276 let mut parts = value.split('-');
4277 let year = parts.next()?.parse().ok()?;
4278 let month = Month::try_from(parts.next()?.parse::<u8>().ok()?).ok()?;
4279 let day = parts.next()?.parse().ok()?;
4280 if parts.next().is_some() {
4281 return None;
4282 }
4283 CalendarDate::from_ymd(year, month, day).ok()
4284 }
4285
4286 fn parse_time(value: &str) -> Option<Time> {
4287 let mut parts = value.split(':');
4288 let hour = parts.next()?.parse().ok()?;
4289 let minute = parts.next()?.parse().ok()?;
4290 let second = parts.next().unwrap_or("0").parse().ok()?;
4291 if parts.next().is_some() {
4292 return None;
4293 }
4294 Time::from_hms(hour, minute, second).ok()
4295 }
4296
4297 fn graph_string(value: &Value, key: &str) -> Option<String> {
4298 value.get(key)?.as_str().map(ToOwned::to_owned)
4299 }
4300
4301 fn graph_bool(value: &Value, key: &str) -> Option<bool> {
4302 value.get(key)?.as_bool()
4303 }
4304
4305 fn graph_i64(value: &Value, key: &str) -> Option<i64> {
4306 value.get(key)?.as_i64()
4307 }
4308
4309 fn microsoft_event_app_id(account_id: &str, calendar_id: &str, graph_id: &str) -> String {
4310 format!("microsoft:{account_id}:{calendar_id}:{graph_id}")
4311 }
4312
4313 fn google_event_app_id(account_id: &str, calendar_id: &str, google_id: &str) -> String {
4314 format!("google:{account_id}:{calendar_id}:{google_id}")
4315 }
4316
4317 fn short_calendar_label(calendar_id: &str) -> String {
4318 const MAX: usize = 18;
4319 let label = calendar_id.chars().take(MAX).collect::<String>();
4320 if calendar_id.chars().count() > MAX {
4321 format!("{label}...")
4322 } else {
4323 label
4324 }
4325 }
4326
4327 fn anchor_label(anchor: OccurrenceAnchor) -> String {
4328 match anchor {
4329 OccurrenceAnchor::AllDay { date } => date.to_string(),
4330 OccurrenceAnchor::Timed { start } => {
4331 format!(
4332 "{}T{:02}:{:02}",
4333 start.date,
4334 start.time.hour(),
4335 start.time.minute()
4336 )
4337 }
4338 }
4339 }
4340
4341 fn current_epoch_seconds() -> u64 {
4342 SystemTime::now()
4343 .duration_since(UNIX_EPOCH)
4344 .map(|duration| duration.as_secs())
4345 .unwrap_or_default()
4346 }
4347
4348 fn form_body(params: &[(&str, &str)]) -> String {
4349 params
4350 .iter()
4351 .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
4352 .collect::<Vec<_>>()
4353 .join("&")
4354 }
4355
4356 fn percent_encode(value: &str) -> String {
4357 let mut encoded = String::new();
4358 for byte in value.bytes() {
4359 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
4360 encoded.push(char::from(byte));
4361 } else {
4362 encoded.push_str(&format!("%{byte:02X}"));
4363 }
4364 }
4365 encoded
4366 }
4367
4368 fn percent_decode(value: &str) -> String {
4369 let mut output = Vec::new();
4370 let bytes = value.as_bytes();
4371 let mut index = 0;
4372 while index < bytes.len() {
4373 if bytes[index] == b'%'
4374 && index + 2 < bytes.len()
4375 && let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16)
4376 {
4377 output.push(hex);
4378 index += 3;
4379 continue;
4380 }
4381 output.push(if bytes[index] == b'+' {
4382 b' '
4383 } else {
4384 bytes[index]
4385 });
4386 index += 1;
4387 }
4388 String::from_utf8_lossy(&output).into_owned()
4389 }
4390
4391 fn parse_query(query: &str) -> BTreeMap<String, String> {
4392 query
4393 .split('&')
4394 .filter_map(|part| {
4395 let (key, value) = part.split_once('=')?;
4396 Some((percent_decode(key), percent_decode(value)))
4397 })
4398 .collect()
4399 }
4400
4401 fn write_oauth_callback_response(
4402 stream: &mut impl Write,
4403 provider_name: &str,
4404 success: bool,
4405 message: &str,
4406 ) -> io::Result<()> {
4407 let status = if success { "200 OK" } else { "400 Bad Request" };
4408 let heading = if success {
4409 format!("rcal {provider_name} login complete")
4410 } else {
4411 format!("rcal {provider_name} login failed")
4412 };
4413 let body = format!("{heading}\n\n{message}\n");
4414 write!(
4415 stream,
4416 "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4417 body.len(),
4418 body
4419 )
4420 }
4421
4422 fn pkce_verifier() -> String {
4423 let mut bytes = [0_u8; 32];
4424 if let Ok(mut file) = fs::File::open("/dev/urandom") {
4425 let _ = file.read_exact(&mut bytes);
4426 } else {
4427 bytes[..8].copy_from_slice(&current_epoch_seconds().to_le_bytes());
4428 }
4429 base64_url_no_pad(&bytes)
4430 }
4431
4432 fn pkce_challenge(verifier: &str) -> String {
4433 let digest = Sha256::digest(verifier.as_bytes());
4434 base64_url_no_pad(&digest)
4435 }
4436
4437 fn base64_url_no_pad(bytes: &[u8]) -> String {
4438 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
4439 let mut output = String::new();
4440 let mut index = 0;
4441 while index + 3 <= bytes.len() {
4442 let chunk = &bytes[index..index + 3];
4443 output.push(TABLE[(chunk[0] >> 2) as usize] as char);
4444 output.push(TABLE[(((chunk[0] & 0b11) << 4) | (chunk[1] >> 4)) as usize] as char);
4445 output.push(TABLE[(((chunk[1] & 0b1111) << 2) | (chunk[2] >> 6)) as usize] as char);
4446 output.push(TABLE[(chunk[2] & 0b111111) as usize] as char);
4447 index += 3;
4448 }
4449 match bytes.len() - index {
4450 1 => {
4451 let byte = bytes[index];
4452 output.push(TABLE[(byte >> 2) as usize] as char);
4453 output.push(TABLE[((byte & 0b11) << 4) as usize] as char);
4454 }
4455 2 => {
4456 let first = bytes[index];
4457 let second = bytes[index + 1];
4458 output.push(TABLE[(first >> 2) as usize] as char);
4459 output.push(TABLE[(((first & 0b11) << 4) | (second >> 4)) as usize] as char);
4460 output.push(TABLE[((second & 0b1111) << 2) as usize] as char);
4461 }
4462 _ => {}
4463 }
4464 output
4465 }
4466
4467 fn base64_url_decode_no_pad(input: &str) -> Option<Vec<u8>> {
4468 let mut output = Vec::new();
4469 let mut buffer = 0_u32;
4470 let mut bits = 0_u8;
4471 for byte in input.bytes() {
4472 if byte == b'=' {
4473 break;
4474 }
4475 let value = match byte {
4476 b'A'..=b'Z' => byte - b'A',
4477 b'a'..=b'z' => byte - b'a' + 26,
4478 b'0'..=b'9' => byte - b'0' + 52,
4479 b'-' => 62,
4480 b'_' => 63,
4481 _ => return None,
4482 } as u32;
4483 buffer = (buffer << 6) | value;
4484 bits += 6;
4485 if bits >= 8 {
4486 bits -= 8;
4487 output.push(((buffer >> bits) & 0xff) as u8);
4488 buffer &= (1 << bits) - 1;
4489 }
4490 }
4491 Some(output)
4492 }
4493
4494 fn open_browser(url: &str) -> Result<(), ProviderError> {
4495 #[cfg(target_os = "macos")]
4496 let result = Command::new("open").arg(url).status();
4497 #[cfg(target_os = "linux")]
4498 let result = Command::new("xdg-open").arg(url).status();
4499 #[cfg(target_os = "windows")]
4500 let result = Command::new("cmd").args(["/C", "start", url]).status();
4501 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
4502 let result: Result<std::process::ExitStatus, io::Error> =
4503 Err(io::Error::new(io::ErrorKind::Other, "unsupported platform"));
4504
4505 match result {
4506 Ok(status) if status.success() => Ok(()),
4507 Ok(status) => Err(ProviderError::Auth(format!(
4508 "failed to open browser: exited with {status}"
4509 ))),
4510 Err(err) => Err(ProviderError::Auth(format!(
4511 "failed to open browser: {err}"
4512 ))),
4513 }
4514 }
4515
4516 #[derive(Debug, Clone, PartialEq, Eq)]
4517 pub enum ProviderError {
4518 Config(String),
4519 Auth(String),
4520 Keyring(String),
4521 Http(String),
4522 Graph(String),
4523 Api(String),
4524 Mapping(String),
4525 Validation(String),
4526 NotFound(String),
4527 CacheRead { path: PathBuf, reason: String },
4528 CacheParse { path: PathBuf, reason: String },
4529 CacheWrite { path: PathBuf, reason: String },
4530 Agenda(String),
4531 }
4532
4533 impl fmt::Display for ProviderError {
4534 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4535 match self {
4536 Self::Config(reason) => write!(f, "provider config error: {reason}"),
4537 Self::Auth(reason) => write!(f, "provider auth error: {reason}"),
4538 Self::Keyring(reason) => write!(f, "provider token keyring error: {reason}"),
4539 Self::Http(reason) => write!(f, "provider HTTP error: {reason}"),
4540 Self::Graph(reason) => write!(f, "Microsoft Graph error: {reason}"),
4541 Self::Api(reason) => write!(f, "provider API error: {reason}"),
4542 Self::Mapping(reason) => write!(f, "provider event mapping error: {reason}"),
4543 Self::Validation(reason) => write!(f, "{reason}"),
4544 Self::NotFound(id) => write!(f, "provider event '{id}' was not found"),
4545 Self::CacheRead { path, reason } => {
4546 write!(
4547 f,
4548 "failed to read provider cache {}: {reason}",
4549 path.display()
4550 )
4551 }
4552 Self::CacheParse { path, reason } => {
4553 write!(
4554 f,
4555 "failed to parse provider cache {}: {reason}",
4556 path.display()
4557 )
4558 }
4559 Self::CacheWrite { path, reason } => {
4560 write!(
4561 f,
4562 "failed to write provider cache {}: {reason}",
4563 path.display()
4564 )
4565 }
4566 Self::Agenda(reason) => write!(f, "{reason}"),
4567 }
4568 }
4569 }
4570
4571 impl Error for ProviderError {}
4572
4573 impl From<AgendaError> for ProviderError {
4574 fn from(err: AgendaError) -> Self {
4575 Self::Agenda(err.to_string())
4576 }
4577 }
4578
4579 #[cfg(test)]
4580 mod tests {
4581 use super::*;
4582 use crate::agenda::{CreateEventTiming, RecurrenceEnd, RecurrenceFrequency};
4583 use std::{
4584 cell::RefCell,
4585 collections::{HashMap, VecDeque},
4586 sync::atomic::{AtomicUsize, Ordering},
4587 };
4588
4589 static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
4590
4591 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
4592 CalendarDate::from_ymd(year, month, day).expect("valid date")
4593 }
4594
4595 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
4596 EventDateTime::new(date, Time::from_hms(hour, minute, 0).expect("valid time"))
4597 }
4598
4599 fn temp_path(name: &str) -> PathBuf {
4600 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
4601 env::temp_dir()
4602 .join(format!(
4603 "rcal-provider-test-{}-{counter}",
4604 std::process::id()
4605 ))
4606 .join(name)
4607 }
4608
4609 fn account() -> MicrosoftAccountConfig {
4610 MicrosoftAccountConfig {
4611 id: "work".to_string(),
4612 client_id: "client-id".to_string(),
4613 tenant: "organizations".to_string(),
4614 redirect_port: 8765,
4615 calendars: vec!["cal".to_string()],
4616 }
4617 }
4618
4619 fn provider_config(cache_file: PathBuf) -> MicrosoftProviderConfig {
4620 MicrosoftProviderConfig {
4621 enabled: true,
4622 default_account: Some("work".to_string()),
4623 default_calendar: Some("cal".to_string()),
4624 sync_past_days: 30,
4625 sync_future_days: 365,
4626 cache_file,
4627 accounts: vec![account()],
4628 }
4629 }
4630
4631 fn google_account() -> GoogleAccountConfig {
4632 GoogleAccountConfig {
4633 id: "personal".to_string(),
4634 client_id: "google-client".to_string(),
4635 client_secret: Some("google-secret".to_string()),
4636 redirect_port: 8766,
4637 calendars: vec!["primary".to_string()],
4638 }
4639 }
4640
4641 fn google_provider_config(cache_file: PathBuf) -> GoogleProviderConfig {
4642 GoogleProviderConfig {
4643 enabled: true,
4644 default_account: Some("personal".to_string()),
4645 default_calendar: Some("primary".to_string()),
4646 sync_past_days: 30,
4647 sync_future_days: 365,
4648 cache_file,
4649 accounts: vec![google_account()],
4650 }
4651 }
4652
4653 fn google_calendar_record(id: &str, name: &str, can_edit: bool) -> GoogleCalendarRecord {
4654 GoogleCalendarRecord {
4655 id: id.to_string(),
4656 name: name.to_string(),
4657 can_edit,
4658 is_default: false,
4659 }
4660 }
4661
4662 fn calendar_record(id: &str, name: &str, can_edit: bool) -> MicrosoftCalendarRecord {
4663 MicrosoftCalendarRecord {
4664 id: id.to_string(),
4665 name: name.to_string(),
4666 can_edit,
4667 is_default: false,
4668 }
4669 }
4670
4671 #[derive(Default)]
4672 struct MemoryTokenStore {
4673 tokens: RefCell<HashMap<String, MicrosoftToken>>,
4674 }
4675
4676 impl MemoryTokenStore {
4677 fn with_token(account_id: &str) -> Self {
4678 let store = Self::default();
4679 store.tokens.borrow_mut().insert(
4680 account_id.to_string(),
4681 MicrosoftToken {
4682 access_token: "access-token".to_string(),
4683 refresh_token: "refresh-token".to_string(),
4684 expires_at_epoch_seconds: current_epoch_seconds().saturating_add(3600),
4685 },
4686 );
4687 store
4688 }
4689 }
4690
4691 impl MicrosoftTokenStore for MemoryTokenStore {
4692 fn load(&self, account_id: &str) -> Result<Option<MicrosoftToken>, ProviderError> {
4693 Ok(self.tokens.borrow().get(account_id).cloned())
4694 }
4695
4696 fn save(&self, account_id: &str, token: &MicrosoftToken) -> Result<(), ProviderError> {
4697 self.tokens
4698 .borrow_mut()
4699 .insert(account_id.to_string(), token.clone());
4700 Ok(())
4701 }
4702
4703 fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
4704 self.tokens.borrow_mut().remove(account_id);
4705 Ok(())
4706 }
4707 }
4708
4709 #[derive(Default)]
4710 struct GoogleMemoryTokenStore {
4711 tokens: RefCell<HashMap<String, GoogleToken>>,
4712 }
4713
4714 impl GoogleMemoryTokenStore {
4715 fn with_token(account_id: &str) -> Self {
4716 let store = Self::default();
4717 store.tokens.borrow_mut().insert(
4718 account_id.to_string(),
4719 GoogleToken {
4720 access_token: "access-token".to_string(),
4721 refresh_token: "refresh-token".to_string(),
4722 expires_at_epoch_seconds: current_epoch_seconds().saturating_add(3600),
4723 },
4724 );
4725 store
4726 }
4727 }
4728
4729 impl GoogleTokenStore for GoogleMemoryTokenStore {
4730 fn load(&self, account_id: &str) -> Result<Option<GoogleToken>, ProviderError> {
4731 Ok(self.tokens.borrow().get(account_id).cloned())
4732 }
4733
4734 fn save(&self, account_id: &str, token: &GoogleToken) -> Result<(), ProviderError> {
4735 self.tokens
4736 .borrow_mut()
4737 .insert(account_id.to_string(), token.clone());
4738 Ok(())
4739 }
4740
4741 fn delete(&self, account_id: &str) -> Result<(), ProviderError> {
4742 self.tokens.borrow_mut().remove(account_id);
4743 Ok(())
4744 }
4745 }
4746
4747 struct RecordingHttpClient {
4748 responses: RefCell<VecDeque<MicrosoftHttpResponse>>,
4749 requests: RefCell<Vec<MicrosoftHttpRequest>>,
4750 }
4751
4752 impl RecordingHttpClient {
4753 fn new(responses: Vec<MicrosoftHttpResponse>) -> Self {
4754 Self {
4755 responses: RefCell::new(VecDeque::from(responses)),
4756 requests: RefCell::new(Vec::new()),
4757 }
4758 }
4759
4760 fn json(status: u16, value: Value) -> MicrosoftHttpResponse {
4761 MicrosoftHttpResponse {
4762 status,
4763 headers: Vec::new(),
4764 body: value.to_string(),
4765 }
4766 }
4767
4768 fn text_with_header(
4769 status: u16,
4770 body: &str,
4771 name: &str,
4772 value: &str,
4773 ) -> MicrosoftHttpResponse {
4774 MicrosoftHttpResponse {
4775 status,
4776 headers: vec![(name.to_string(), value.to_string())],
4777 body: body.to_string(),
4778 }
4779 }
4780 }
4781
4782 impl MicrosoftHttpClient for RecordingHttpClient {
4783 fn request(
4784 &self,
4785 request: MicrosoftHttpRequest,
4786 ) -> Result<MicrosoftHttpResponse, ProviderError> {
4787 self.requests.borrow_mut().push(request);
4788 self.responses
4789 .borrow_mut()
4790 .pop_front()
4791 .ok_or_else(|| ProviderError::Http("unexpected request".to_string()))
4792 }
4793 }
4794
4795 #[test]
4796 fn graph_timed_event_maps_to_rcal_event() {
4797 let calendar = MicrosoftCalendarRecord {
4798 id: "cal".to_string(),
4799 name: "Work".to_string(),
4800 can_edit: true,
4801 is_default: true,
4802 };
4803 let raw = json!({
4804 "id": "abc",
4805 "subject": "Standup",
4806 "type": "singleInstance",
4807 "isAllDay": false,
4808 "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
4809 "end": {"dateTime": "2026-04-23T09:30:00", "timeZone": "UTC"},
4810 "location": {"displayName": "Room"},
4811 "bodyPreview": "Notes",
4812 "isReminderOn": true,
4813 "reminderMinutesBeforeStart": 15
4814 });
4815
4816 let cached = MicrosoftCachedEvent::from_graph("work", &calendar, raw).expect("maps");
4817 let event = cached.to_event().expect("event converts");
4818
4819 assert_eq!(event.id, "microsoft:work:cal:abc");
4820 assert_eq!(event.title, "Standup");
4821 assert_eq!(event.location.as_deref(), Some("Room"));
4822 assert_eq!(event.reminders, vec![Reminder::minutes_before(15)]);
4823 assert!(event.source.source_id.starts_with("microsoft:work:cal"));
4824 }
4825
4826 #[test]
4827 fn google_timed_event_maps_to_rcal_event() {
4828 let calendar = google_calendar_record("primary", "Calendar", true);
4829 let raw = json!({
4830 "id": "abc",
4831 "summary": "Standup",
4832 "eventType": "default",
4833 "status": "confirmed",
4834 "start": {"dateTime": "2026-04-23T09:00:00Z"},
4835 "end": {"dateTime": "2026-04-23T09:30:00Z"},
4836 "location": "Room",
4837 "description": "Notes",
4838 "reminders": {
4839 "useDefault": false,
4840 "overrides": [
4841 {"method": "popup", "minutes": 10},
4842 {"method": "email", "minutes": 60}
4843 ]
4844 }
4845 });
4846
4847 let cached = GoogleCachedEvent::from_google("personal", &calendar, raw).expect("maps");
4848 let event = cached.to_event().expect("event converts");
4849
4850 assert_eq!(event.id, "google:personal:primary:abc");
4851 assert_eq!(event.title, "Standup");
4852 assert_eq!(event.location.as_deref(), Some("Room"));
4853 assert_eq!(event.notes.as_deref(), Some("Notes"));
4854 assert_eq!(event.reminders.len(), 2);
4855 assert_eq!(event.source.source_id, "google:personal:primary");
4856 }
4857
4858 #[test]
4859 fn google_rrule_payload_and_parse_cover_weekly_multi_day() {
4860 let draft = CreateEventDraft {
4861 title: "Class".to_string(),
4862 timing: CreateEventTiming::Timed {
4863 start: EventDateTime::new(
4864 date(2026, Month::April, 23),
4865 Time::from_hms(13, 50, 0).unwrap(),
4866 ),
4867 end: EventDateTime::new(
4868 date(2026, Month::April, 23),
4869 Time::from_hms(14, 40, 0).unwrap(),
4870 ),
4871 },
4872 location: None,
4873 notes: None,
4874 reminders: Vec::new(),
4875 recurrence: Some(RecurrenceRule {
4876 frequency: RecurrenceFrequency::Weekly,
4877 interval: 1,
4878 end: RecurrenceEnd::Count(9),
4879 weekdays: vec![Weekday::Monday, Weekday::Wednesday, Weekday::Friday],
4880 monthly: None,
4881 yearly: None,
4882 }),
4883 };
4884
4885 let rule = google_rrule_payload(draft.recurrence.as_ref().unwrap(), &draft);
4886 let parsed = google_rrule_to_cache(&rule)
4887 .and_then(|cache| cache.to_rule())
4888 .expect("rrule parses");
4889
4890 assert_eq!(rule, "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=9");
4891 assert_eq!(parsed.frequency, RecurrenceFrequency::Weekly);
4892 assert_eq!(parsed.weekdays, draft.recurrence.as_ref().unwrap().weekdays);
4893 assert_eq!(parsed.end, RecurrenceEnd::Count(9));
4894 }
4895
4896 #[test]
4897 fn google_sync_writes_selected_calendar_cache_and_renders_event() {
4898 let cache_file = temp_path("google-sync/google-cache.json");
4899 let _ = fs::remove_file(&cache_file);
4900 let store = GoogleMemoryTokenStore::with_token("personal");
4901 let http = RecordingHttpClient::new(vec![
4902 RecordingHttpClient::json(
4903 200,
4904 json!({
4905 "id": "primary",
4906 "summary": "Calendar",
4907 "accessRole": "owner",
4908 "primary": true
4909 }),
4910 ),
4911 RecordingHttpClient::json(
4912 200,
4913 json!({
4914 "items": [
4915 {
4916 "id": "evt",
4917 "summary": "Planning",
4918 "eventType": "default",
4919 "status": "confirmed",
4920 "start": {"dateTime": "2026-04-23T09:00:00Z"},
4921 "end": {"dateTime": "2026-04-23T10:00:00Z"}
4922 }
4923 ]
4924 }),
4925 ),
4926 ]);
4927 let mut runtime =
4928 GoogleProviderRuntime::load(google_provider_config(cache_file.clone())).expect("load");
4929
4930 let summary = runtime
4931 .sync(
4932 Some("personal"),
4933 &http,
4934 &store,
4935 date(2026, Month::April, 23),
4936 )
4937 .expect("sync succeeds");
4938 let source = GoogleAgendaSource::load(&cache_file).expect("cache reloads");
4939 let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 23)));
4940 let _ = fs::remove_file(&cache_file);
4941
4942 assert_eq!(summary.events, 1);
4943 assert_eq!(events.len(), 1);
4944 assert_eq!(events[0].id, "google:personal:primary:evt");
4945 assert_eq!(events[0].source.source_id, "google:personal:primary");
4946 }
4947
4948 #[test]
4949 fn google_sync_fetches_recurring_master_without_render_duplicate() {
4950 let cache_file = temp_path("google-series/google-cache.json");
4951 let _ = fs::remove_file(&cache_file);
4952 let store = GoogleMemoryTokenStore::with_token("personal");
4953 let http = RecordingHttpClient::new(vec![
4954 RecordingHttpClient::json(
4955 200,
4956 json!({
4957 "id": "primary",
4958 "summary": "Calendar",
4959 "accessRole": "owner",
4960 "primary": true
4961 }),
4962 ),
4963 RecordingHttpClient::json(
4964 200,
4965 json!({
4966 "items": [
4967 {
4968 "id": "occ-1",
4969 "summary": "Class",
4970 "eventType": "default",
4971 "status": "confirmed",
4972 "recurringEventId": "master",
4973 "originalStartTime": {"dateTime": "2026-04-23T13:50:00Z"},
4974 "start": {"dateTime": "2026-04-23T13:50:00Z"},
4975 "end": {"dateTime": "2026-04-23T14:40:00Z"}
4976 }
4977 ]
4978 }),
4979 ),
4980 RecordingHttpClient::json(
4981 200,
4982 json!({
4983 "id": "master",
4984 "summary": "Class",
4985 "eventType": "default",
4986 "status": "confirmed",
4987 "start": {"dateTime": "2026-04-20T13:50:00Z"},
4988 "end": {"dateTime": "2026-04-20T14:40:00Z"},
4989 "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"]
4990 }),
4991 ),
4992 ]);
4993 let mut runtime =
4994 GoogleProviderRuntime::load(google_provider_config(cache_file.clone())).expect("load");
4995
4996 runtime
4997 .sync(
4998 Some("personal"),
4999 &http,
5000 &store,
5001 date(2026, Month::April, 23),
5002 )
5003 .expect("sync succeeds");
5004 let source = GoogleAgendaSource::load(&cache_file).expect("cache reloads");
5005 let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 23)));
5006 let master = source
5007 .editable_event_by_id("google:personal:primary:master")
5008 .expect("series master cached for edit");
5009 let _ = fs::remove_file(&cache_file);
5010
5011 assert_eq!(events.len(), 1);
5012 assert_eq!(events[0].id, "google:personal:primary:occ-1");
5013 assert_eq!(
5014 events[0]
5015 .occurrence
5016 .as_ref()
5017 .map(|occurrence| occurrence.series_id.as_str()),
5018 Some("google:personal:primary:master")
5019 );
5020 assert!(master.recurrence.is_some());
5021 }
5022
5023 #[test]
5024 fn google_oauth_errors_parse_top_level_error_response() {
5025 let response = RecordingHttpClient::json(
5026 400,
5027 json!({
5028 "error": "invalid_request",
5029 "error_description": "client_secret is missing."
5030 }),
5031 );
5032
5033 let err = google_token_from_response(response, None).expect_err("400 fails");
5034
5035 assert_eq!(
5036 err.to_string(),
5037 "provider auth error: Google OAuth 400: invalid_request: client_secret is missing."
5038 );
5039 }
5040
5041 #[test]
5042 fn provider_write_targets_use_configured_editable_calendars() {
5043 let cache_file = temp_path("targets/microsoft-cache.json");
5044 let mut config = provider_config(cache_file);
5045 config.default_calendar = Some("team".to_string());
5046 config.accounts[0].calendars = vec![
5047 "cal".to_string(),
5048 "team".to_string(),
5049 "holidays".to_string(),
5050 ];
5051 let mut runtime = MicrosoftProviderRuntime::load(config).expect("load");
5052 runtime
5053 .cache
5054 .replace_calendar("work", calendar_record("cal", "Work", true), Vec::new(), 0);
5055 runtime.cache.replace_calendar(
5056 "work",
5057 calendar_record("holidays", "Holidays", false),
5058 Vec::new(),
5059 0,
5060 );
5061
5062 let targets = runtime.write_targets();
5063
5064 assert_eq!(targets.len(), 2);
5065 assert_eq!(targets[0].label, "Microsoft work: Work");
5066 assert_eq!(targets[1].label, "Microsoft work: team");
5067 assert_eq!(
5068 runtime.default_write_target(),
5069 Some(EventWriteTargetId::microsoft("work", "team"))
5070 );
5071 }
5072
5073 #[test]
5074 fn create_event_uses_explicit_calendar_target() {
5075 let cache_file = temp_path("create-target/microsoft-cache.json");
5076 let mut config = provider_config(cache_file.clone());
5077 config.accounts[0].calendars = vec!["cal".to_string(), "personal".to_string()];
5078 let store = MemoryTokenStore::with_token("work");
5079 let http = RecordingHttpClient::new(vec![
5080 RecordingHttpClient::json(
5081 201,
5082 json!({
5083 "id": "evt",
5084 "subject": "Planning",
5085 "type": "singleInstance",
5086 "isAllDay": false,
5087 "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
5088 "end": {"dateTime": "2026-04-23T10:00:00", "timeZone": "UTC"}
5089 }),
5090 ),
5091 RecordingHttpClient::json(
5092 200,
5093 json!({
5094 "id": "personal",
5095 "name": "Personal",
5096 "canEdit": true,
5097 "isDefaultCalendar": false
5098 }),
5099 ),
5100 ]);
5101 let mut runtime = MicrosoftProviderRuntime::load(config).expect("load");
5102 let day = date(2026, Month::April, 23);
5103 let draft = CreateEventDraft {
5104 title: "Planning".to_string(),
5105 timing: CreateEventTiming::Timed {
5106 start: at(day, 9, 0),
5107 end: at(day, 10, 0),
5108 },
5109 location: None,
5110 notes: None,
5111 reminders: Vec::new(),
5112 recurrence: None,
5113 };
5114 let target = EventWriteTargetId::microsoft("work", "personal");
5115
5116 let event = runtime
5117 .create_event_in_target(draft, &target, &http, &store)
5118 .expect("create succeeds");
5119
5120 let _ = fs::remove_dir_all(
5121 cache_file
5122 .parent()
5123 .and_then(Path::parent)
5124 .expect("test root"),
5125 );
5126 assert_eq!(event.id, "microsoft:work:personal:evt");
5127 assert_eq!(
5128 http.requests.borrow()[0].url,
5129 format!("{GRAPH_BASE_URL}/me/calendars/personal/events")
5130 );
5131 }
5132
5133 #[test]
5134 fn graph_all_day_recurring_occurrence_maps_anchor() {
5135 let calendar = MicrosoftCalendarRecord {
5136 id: "cal".to_string(),
5137 name: "Work".to_string(),
5138 can_edit: true,
5139 is_default: true,
5140 };
5141 let raw = json!({
5142 "id": "occ",
5143 "subject": "OOO",
5144 "type": "occurrence",
5145 "seriesMasterId": "master",
5146 "isAllDay": true,
5147 "start": {"dateTime": "2026-04-23T00:00:00", "timeZone": "UTC"},
5148 "end": {"dateTime": "2026-04-24T00:00:00", "timeZone": "UTC"}
5149 });
5150
5151 let event = MicrosoftCachedEvent::from_graph("work", &calendar, raw)
5152 .expect("maps")
5153 .to_event()
5154 .expect("event converts");
5155
5156 assert_eq!(
5157 event.occurrence(),
5158 Some(&OccurrenceMetadata {
5159 series_id: "microsoft:work:cal:master".to_string(),
5160 anchor: OccurrenceAnchor::AllDay {
5161 date: date(2026, Month::April, 23)
5162 },
5163 })
5164 );
5165 }
5166
5167 #[test]
5168 fn draft_payload_rejects_multiple_microsoft_reminders() {
5169 let draft = CreateEventDraft {
5170 title: "Focus".to_string(),
5171 timing: CreateEventTiming::Timed {
5172 start: at(date(2026, Month::April, 23), 9, 0),
5173 end: at(date(2026, Month::April, 23), 10, 0),
5174 },
5175 location: None,
5176 notes: None,
5177 reminders: vec![Reminder::minutes_before(5), Reminder::minutes_before(10)],
5178 recurrence: None,
5179 };
5180
5181 let err = graph_event_payload(&draft, false).expect_err("multiple reminders fail");
5182 assert!(err.to_string().contains("only one reminder"));
5183 }
5184
5185 #[test]
5186 fn weekly_recurrence_payload_uses_graph_pattern() {
5187 let draft = CreateEventDraft {
5188 title: "Class".to_string(),
5189 timing: CreateEventTiming::Timed {
5190 start: at(date(2026, Month::April, 20), 13, 50),
5191 end: at(date(2026, Month::April, 20), 14, 40),
5192 },
5193 location: None,
5194 notes: None,
5195 reminders: Vec::new(),
5196 recurrence: Some(RecurrenceRule {
5197 frequency: RecurrenceFrequency::Weekly,
5198 interval: 1,
5199 end: RecurrenceEnd::Count(10),
5200 weekdays: vec![Weekday::Monday, Weekday::Wednesday, Weekday::Friday],
5201 monthly: None,
5202 yearly: None,
5203 }),
5204 };
5205
5206 let payload = graph_event_payload(&draft, false).expect("payload builds");
5207 let recurrence = payload.get("recurrence").expect("recurrence included");
5208
5209 assert_eq!(recurrence["pattern"]["type"], "weekly");
5210 assert_eq!(
5211 recurrence["pattern"]["daysOfWeek"],
5212 json!(["monday", "wednesday", "friday"])
5213 );
5214 assert_eq!(recurrence["range"]["type"], "numbered");
5215 assert_eq!(recurrence["range"]["numberOfOccurrences"], 10);
5216 }
5217
5218 #[test]
5219 fn list_calendars_uses_stored_token_and_maps_response() {
5220 let store = MemoryTokenStore::with_token("work");
5221 let http = RecordingHttpClient::new(vec![RecordingHttpClient::json(
5222 200,
5223 json!({
5224 "value": [
5225 {
5226 "id": "cal",
5227 "name": "Calendar",
5228 "canEdit": true,
5229 "isDefaultCalendar": true
5230 }
5231 ]
5232 }),
5233 )]);
5234
5235 let calendars = list_calendars(&account(), &http, &store).expect("calendars list");
5236
5237 assert_eq!(
5238 calendars,
5239 vec![MicrosoftCalendarInfo {
5240 id: "cal".to_string(),
5241 name: "Calendar".to_string(),
5242 can_edit: true,
5243 is_default: true,
5244 }]
5245 );
5246 let requests = http.requests.borrow();
5247 assert_eq!(requests[0].method, "GET");
5248 assert!(
5249 requests[0]
5250 .url
5251 .ends_with("/me/calendars?$select=id,name,canEdit,isDefaultCalendar")
5252 );
5253 assert!(
5254 requests[0]
5255 .headers
5256 .iter()
5257 .any(|(name, value)| name == "Authorization" && value == "Bearer access-token")
5258 );
5259 }
5260
5261 #[test]
5262 fn graph_errors_include_www_authenticate_header_when_body_is_empty() {
5263 let response = RecordingHttpClient::text_with_header(
5264 401,
5265 "",
5266 "WWW-Authenticate",
5267 "Bearer error=\"invalid_token\", error_description=\"Invalid audience\"",
5268 );
5269
5270 let err = parse_graph_success_json(response).expect_err("401 fails");
5271
5272 assert_eq!(
5273 err.to_string(),
5274 "Microsoft Graph error: HTTP 401: Bearer error=\"invalid_token\", error_description=\"Invalid audience\""
5275 );
5276 }
5277
5278 #[test]
5279 fn inspect_token_reports_safe_jwt_claims_without_token_body() {
5280 let store = MemoryTokenStore::default();
5281 let claims = json!({
5282 "aud": "https://graph.microsoft.com",
5283 "scp": "User.Read Calendars.ReadWrite",
5284 "tid": "tenant-id",
5285 "iss": "https://sts.windows.net/tenant-id/",
5286 "appid": "app-id",
5287 "azp": "authorized-party",
5288 "exp": 1_777_000_000
5289 });
5290 let access_token = format!(
5291 "{}.{}.signature",
5292 base64_url_no_pad(br#"{"alg":"none"}"#),
5293 base64_url_no_pad(claims.to_string().as_bytes())
5294 );
5295 store.tokens.borrow_mut().insert(
5296 "work".to_string(),
5297 MicrosoftToken {
5298 access_token,
5299 refresh_token: "refresh".to_string(),
5300 expires_at_epoch_seconds: 1_777_000_100,
5301 },
5302 );
5303
5304 let inspection = inspect_token("work", &store).expect("token inspects");
5305
5306 assert_eq!(inspection.account_id, "work");
5307 assert_eq!(inspection.token_format, "jwt");
5308 assert_eq!(
5309 inspection.audience.as_deref(),
5310 Some("https://graph.microsoft.com")
5311 );
5312 assert_eq!(
5313 inspection.scopes.as_deref(),
5314 Some("User.Read Calendars.ReadWrite")
5315 );
5316 assert_eq!(inspection.tenant_id.as_deref(), Some("tenant-id"));
5317 assert_eq!(inspection.jwt_expires_at_epoch_seconds, Some(1_777_000_000));
5318 assert!(inspection.has_refresh_token);
5319 }
5320
5321 #[test]
5322 fn inspect_token_tolerates_opaque_access_tokens() {
5323 let store = MemoryTokenStore::default();
5324 store.tokens.borrow_mut().insert(
5325 "work".to_string(),
5326 MicrosoftToken {
5327 access_token: "opaque-consumer-token".to_string(),
5328 refresh_token: "refresh".to_string(),
5329 expires_at_epoch_seconds: 1_777_000_100,
5330 },
5331 );
5332
5333 let inspection = inspect_token("work", &store).expect("opaque token inspects");
5334
5335 assert_eq!(inspection.token_format, "opaque");
5336 assert_eq!(inspection.audience, None);
5337 assert_eq!(inspection.scopes, None);
5338 assert_eq!(inspection.jwt_expires_at_epoch_seconds, None);
5339 assert!(inspection.has_refresh_token);
5340 }
5341
5342 #[test]
5343 fn oauth_callback_response_surfaces_browser_errors() {
5344 let mut response = Vec::new();
5345
5346 write_oauth_callback_response(
5347 &mut response,
5348 "Microsoft",
5349 false,
5350 "invalid_request: the application must use consumers",
5351 )
5352 .expect("response writes");
5353
5354 let response = String::from_utf8(response).expect("utf8 response");
5355 assert!(response.starts_with("HTTP/1.1 400 Bad Request"));
5356 assert!(response.contains("rcal Microsoft login failed"));
5357 assert!(response.contains("invalid_request"));
5358 }
5359
5360 #[test]
5361 fn sync_writes_selected_calendar_cache_and_renders_provider_event() {
5362 let cache_file = temp_path("sync/microsoft-cache.json");
5363 let _ = fs::remove_dir_all(
5364 cache_file
5365 .parent()
5366 .and_then(Path::parent)
5367 .expect("test root"),
5368 );
5369 let store = MemoryTokenStore::with_token("work");
5370 let http = RecordingHttpClient::new(vec![
5371 RecordingHttpClient::json(
5372 200,
5373 json!({
5374 "id": "cal",
5375 "name": "Work",
5376 "canEdit": true,
5377 "isDefaultCalendar": true
5378 }),
5379 ),
5380 RecordingHttpClient::json(
5381 200,
5382 json!({
5383 "value": [
5384 {
5385 "id": "evt",
5386 "subject": "Focus",
5387 "type": "singleInstance",
5388 "isAllDay": false,
5389 "start": {"dateTime": "2026-04-23T09:00:00", "timeZone": "UTC"},
5390 "end": {"dateTime": "2026-04-23T10:00:00", "timeZone": "UTC"}
5391 }
5392 ]
5393 }),
5394 ),
5395 ]);
5396 let mut runtime =
5397 MicrosoftProviderRuntime::load(provider_config(cache_file.clone())).expect("load");
5398
5399 let summary = runtime
5400 .sync(None, &http, &store, date(2026, Month::April, 23))
5401 .expect("sync succeeds");
5402 let source = MicrosoftAgendaSource::load(&cache_file).expect("cache reloads");
5403 let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 23)));
5404
5405 let _ = fs::remove_dir_all(
5406 cache_file
5407 .parent()
5408 .and_then(Path::parent)
5409 .expect("test root"),
5410 );
5411 assert_eq!(summary.accounts, 1);
5412 assert_eq!(summary.calendars, 1);
5413 assert_eq!(summary.events, 1);
5414 assert_eq!(events.len(), 1);
5415 assert_eq!(events[0].id, "microsoft:work:cal:evt");
5416 assert_eq!(events[0].title, "Focus");
5417 assert!(events[0].is_microsoft());
5418 }
5419
5420 #[test]
5421 fn sync_fetches_series_master_for_provider_series_edit_without_render_duplicate() {
5422 let cache_file = temp_path("series/microsoft-cache.json");
5423 let _ = fs::remove_dir_all(
5424 cache_file
5425 .parent()
5426 .and_then(Path::parent)
5427 .expect("test root"),
5428 );
5429 let store = MemoryTokenStore::with_token("work");
5430 let http = RecordingHttpClient::new(vec![
5431 RecordingHttpClient::json(
5432 200,
5433 json!({
5434 "id": "cal",
5435 "name": "Work",
5436 "canEdit": true,
5437 "isDefaultCalendar": true
5438 }),
5439 ),
5440 RecordingHttpClient::json(
5441 200,
5442 json!({
5443 "value": [
5444 {
5445 "id": "occ-1",
5446 "subject": "Class",
5447 "type": "occurrence",
5448 "seriesMasterId": "master",
5449 "isAllDay": false,
5450 "start": {"dateTime": "2026-04-24T13:50:00", "timeZone": "UTC"},
5451 "end": {"dateTime": "2026-04-24T14:40:00", "timeZone": "UTC"}
5452 }
5453 ]
5454 }),
5455 ),
5456 RecordingHttpClient::json(
5457 200,
5458 json!({
5459 "id": "master",
5460 "subject": "Class",
5461 "type": "seriesMaster",
5462 "isAllDay": false,
5463 "start": {"dateTime": "2026-04-20T13:50:00", "timeZone": "UTC"},
5464 "end": {"dateTime": "2026-04-20T14:40:00", "timeZone": "UTC"},
5465 "recurrence": {
5466 "pattern": {
5467 "type": "weekly",
5468 "interval": 1,
5469 "daysOfWeek": ["monday", "wednesday", "friday"],
5470 "firstDayOfWeek": "sunday"
5471 },
5472 "range": {
5473 "type": "noEnd",
5474 "startDate": "2026-04-20",
5475 "recurrenceTimeZone": "UTC"
5476 }
5477 }
5478 }),
5479 ),
5480 ]);
5481 let mut runtime =
5482 MicrosoftProviderRuntime::load(provider_config(cache_file.clone())).expect("load");
5483
5484 runtime
5485 .sync(None, &http, &store, date(2026, Month::April, 23))
5486 .expect("sync succeeds");
5487 let source = MicrosoftAgendaSource::load(&cache_file).expect("cache reloads");
5488 let events = source.events_intersecting(DateRange::day(date(2026, Month::April, 24)));
5489 let series = source
5490 .editable_event_by_id("microsoft:work:cal:master")
5491 .expect("series master is cached for editing");
5492
5493 let _ = fs::remove_dir_all(
5494 cache_file
5495 .parent()
5496 .and_then(Path::parent)
5497 .expect("test root"),
5498 );
5499 assert_eq!(events.len(), 1);
5500 assert_eq!(events[0].id, "microsoft:work:cal:occ-1");
5501 assert!(events[0].occurrence().is_some());
5502 assert!(series.recurrence.is_some());
5503 }
5504 }
5505