@@ -147,25 +147,32 @@ impl EventWriteTarget { |
| 147 | 147 | } |
| 148 | 148 | } |
| 149 | 149 | |
| 150 | | - pub fn microsoft( |
| 150 | + pub fn provider( |
| 151 | + provider_id: impl Into<String>, |
| 151 | 152 | account_id: impl Into<String>, |
| 152 | 153 | calendar_id: impl Into<String>, |
| 153 | 154 | label: impl Into<String>, |
| 154 | 155 | ) -> Self { |
| 155 | 156 | Self { |
| 156 | | - id: EventWriteTargetId::Microsoft { |
| 157 | | - account_id: account_id.into(), |
| 158 | | - calendar_id: calendar_id.into(), |
| 159 | | - }, |
| 157 | + id: EventWriteTargetId::provider(provider_id, account_id, calendar_id), |
| 160 | 158 | label: label.into(), |
| 161 | 159 | } |
| 162 | 160 | } |
| 161 | + |
| 162 | + pub fn microsoft( |
| 163 | + account_id: impl Into<String>, |
| 164 | + calendar_id: impl Into<String>, |
| 165 | + label: impl Into<String>, |
| 166 | + ) -> Self { |
| 167 | + Self::provider("microsoft", account_id, calendar_id, label) |
| 168 | + } |
| 163 | 169 | } |
| 164 | 170 | |
| 165 | 171 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 166 | 172 | pub enum EventWriteTargetId { |
| 167 | 173 | Local, |
| 168 | | - Microsoft { |
| 174 | + Provider { |
| 175 | + provider_id: String, |
| 169 | 176 | account_id: String, |
| 170 | 177 | calendar_id: String, |
| 171 | 178 | }, |
@@ -176,18 +183,84 @@ impl EventWriteTargetId { |
| 176 | 183 | matches!(self, Self::Local) |
| 177 | 184 | } |
| 178 | 185 | |
| 186 | + pub fn provider( |
| 187 | + provider_id: impl Into<String>, |
| 188 | + account_id: impl Into<String>, |
| 189 | + calendar_id: impl Into<String>, |
| 190 | + ) -> Self { |
| 191 | + Self::Provider { |
| 192 | + provider_id: provider_id.into(), |
| 193 | + account_id: account_id.into(), |
| 194 | + calendar_id: calendar_id.into(), |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + pub fn microsoft(account_id: impl Into<String>, calendar_id: impl Into<String>) -> Self { |
| 199 | + Self::provider("microsoft", account_id, calendar_id) |
| 200 | + } |
| 201 | + |
| 202 | + pub fn provider_id(&self) -> Option<&str> { |
| 203 | + match self { |
| 204 | + Self::Local => None, |
| 205 | + Self::Provider { provider_id, .. } => Some(provider_id), |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + pub fn account_id(&self) -> Option<&str> { |
| 210 | + match self { |
| 211 | + Self::Local => None, |
| 212 | + Self::Provider { account_id, .. } => Some(account_id), |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + pub fn calendar_id(&self) -> Option<&str> { |
| 217 | + match self { |
| 218 | + Self::Local => None, |
| 219 | + Self::Provider { calendar_id, .. } => Some(calendar_id), |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + pub fn provider_parts(&self) -> Option<(&str, &str, &str)> { |
| 224 | + match self { |
| 225 | + Self::Local => None, |
| 226 | + Self::Provider { |
| 227 | + provider_id, |
| 228 | + account_id, |
| 229 | + calendar_id, |
| 230 | + } => Some((provider_id, account_id, calendar_id)), |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + pub fn is_provider(&self, provider: &str) -> bool { |
| 235 | + self.provider_id() == Some(provider) |
| 236 | + } |
| 237 | + |
| 238 | + pub fn is_microsoft(&self) -> bool { |
| 239 | + self.is_provider("microsoft") |
| 240 | + } |
| 241 | + |
| 242 | + pub fn microsoft_parts(&self) -> Option<(&str, &str)> { |
| 243 | + let (provider, account, calendar) = self.provider_parts()?; |
| 244 | + (provider == "microsoft").then_some((account, calendar)) |
| 245 | + } |
| 246 | + |
| 247 | + pub fn source_id(&self) -> Option<String> { |
| 248 | + self.provider_parts() |
| 249 | + .map(|(provider, account, calendar)| format!("{provider}:{account}:{calendar}")) |
| 250 | + } |
| 251 | + |
| 179 | 252 | pub fn from_event(event: &Event) -> Option<Self> { |
| 180 | 253 | if event.is_local() { |
| 181 | 254 | return Some(Self::Local); |
| 182 | 255 | } |
| 183 | 256 | let mut parts = event.source.source_id.splitn(3, ':'); |
| 184 | | - match (parts.next(), parts.next(), parts.next()) { |
| 185 | | - (Some("microsoft"), Some(account_id), Some(calendar_id)) => Some(Self::Microsoft { |
| 186 | | - account_id: account_id.to_string(), |
| 187 | | - calendar_id: calendar_id.to_string(), |
| 188 | | - }), |
| 189 | | - _ => None, |
| 257 | + let provider_id = parts.next()?; |
| 258 | + let account_id = parts.next()?; |
| 259 | + let calendar_id = parts.next()?; |
| 260 | + if provider_id.is_empty() || account_id.is_empty() || calendar_id.is_empty() { |
| 261 | + return None; |
| 190 | 262 | } |
| 263 | + Some(Self::provider(provider_id, account_id, calendar_id)) |
| 191 | 264 | } |
| 192 | 265 | } |
| 193 | 266 | |
@@ -440,8 +513,14 @@ impl Event { |
| 440 | 513 | self.source.source_id.starts_with("microsoft:") |
| 441 | 514 | } |
| 442 | 515 | |
| 516 | + pub fn is_provider_backed(&self) -> bool { |
| 517 | + EventWriteTargetId::from_event(self) |
| 518 | + .as_ref() |
| 519 | + .is_some_and(|target| !target.is_local()) |
| 520 | + } |
| 521 | + |
| 443 | 522 | pub fn is_editable(&self) -> bool { |
| 444 | | - self.is_local() || self.is_microsoft() |
| 523 | + self.is_local() || self.is_provider_backed() |
| 445 | 524 | } |
| 446 | 525 | |
| 447 | 526 | pub const fn is_recurring_series(&self) -> bool { |
@@ -728,19 +807,17 @@ impl ConfiguredAgendaSource { |
| 728 | 807 | draft: CreateEventDraft, |
| 729 | 808 | target: &EventWriteTargetId, |
| 730 | 809 | ) -> Result<Event, LocalEventStoreError> { |
| 731 | | - if let EventWriteTargetId::Microsoft { .. } = target |
| 732 | | - && let Some(microsoft) = &mut self.microsoft |
| 733 | | - { |
| 734 | | - let http = ReqwestMicrosoftHttpClient; |
| 735 | | - let token_store = KeyringMicrosoftTokenStore; |
| 736 | | - return microsoft |
| 737 | | - .create_event_in_target(draft, target, &http, &token_store) |
| 738 | | - .map_err(provider_error); |
| 739 | | - } |
| 740 | | - if matches!(target, EventWriteTargetId::Microsoft { .. }) { |
| 741 | | - return Err(LocalEventStoreError::Provider { |
| 742 | | - reason: "Microsoft provider is not configured".to_string(), |
| 743 | | - }); |
| 810 | + if let Some(provider_id) = target.provider_id() { |
| 811 | + if provider_id == "microsoft" |
| 812 | + && let Some(microsoft) = &mut self.microsoft |
| 813 | + { |
| 814 | + let http = ReqwestMicrosoftHttpClient; |
| 815 | + let token_store = KeyringMicrosoftTokenStore; |
| 816 | + return microsoft |
| 817 | + .create_event_in_target(draft, target, &http, &token_store) |
| 818 | + .map_err(provider_error); |
| 819 | + } |
| 820 | + return Err(provider_not_configured(provider_id)); |
| 744 | 821 | } |
| 745 | 822 | |
| 746 | 823 | self.create_local_event(draft) |
@@ -794,14 +871,18 @@ impl ConfiguredAgendaSource { |
| 794 | 871 | } |
| 795 | 872 | |
| 796 | 873 | if self.events.local_event_by_id(id).is_none() |
| 797 | | - && id.starts_with("microsoft:") |
| 798 | | - && let Some(microsoft) = &mut self.microsoft |
| 874 | + && let Some(provider_id) = event_id_provider(id) |
| 799 | 875 | { |
| 800 | | - let http = ReqwestMicrosoftHttpClient; |
| 801 | | - let token_store = KeyringMicrosoftTokenStore; |
| 802 | | - return microsoft |
| 803 | | - .update_event(id, draft, &http, &token_store) |
| 804 | | - .map_err(provider_error); |
| 876 | + if provider_id == "microsoft" |
| 877 | + && let Some(microsoft) = &mut self.microsoft |
| 878 | + { |
| 879 | + let http = ReqwestMicrosoftHttpClient; |
| 880 | + let token_store = KeyringMicrosoftTokenStore; |
| 881 | + return microsoft |
| 882 | + .update_event(id, draft, &http, &token_store) |
| 883 | + .map_err(provider_error); |
| 884 | + } |
| 885 | + return Err(provider_not_configured(provider_id)); |
| 805 | 886 | } |
| 806 | 887 | |
| 807 | 888 | let mut events = self.events.events().to_vec(); |
@@ -845,14 +926,18 @@ impl ConfiguredAgendaSource { |
| 845 | 926 | draft: CreateEventDraft, |
| 846 | 927 | ) -> Result<Event, LocalEventStoreError> { |
| 847 | 928 | if self.events.local_event_by_id(series_id).is_none() |
| 848 | | - && series_id.starts_with("microsoft:") |
| 849 | | - && let Some(microsoft) = &mut self.microsoft |
| 929 | + && let Some(provider_id) = event_id_provider(series_id) |
| 850 | 930 | { |
| 851 | | - let http = ReqwestMicrosoftHttpClient; |
| 852 | | - let token_store = KeyringMicrosoftTokenStore; |
| 853 | | - return microsoft |
| 854 | | - .update_occurrence(series_id, anchor, draft, &http, &token_store) |
| 855 | | - .map_err(provider_error); |
| 931 | + if provider_id == "microsoft" |
| 932 | + && let Some(microsoft) = &mut self.microsoft |
| 933 | + { |
| 934 | + let http = ReqwestMicrosoftHttpClient; |
| 935 | + let token_store = KeyringMicrosoftTokenStore; |
| 936 | + return microsoft |
| 937 | + .update_occurrence(series_id, anchor, draft, &http, &token_store) |
| 938 | + .map_err(provider_error); |
| 939 | + } |
| 940 | + return Err(provider_not_configured(provider_id)); |
| 856 | 941 | } |
| 857 | 942 | |
| 858 | 943 | let mut events = self.events.events().to_vec(); |
@@ -906,14 +991,18 @@ impl ConfiguredAgendaSource { |
| 906 | 991 | |
| 907 | 992 | pub fn delete_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> { |
| 908 | 993 | if self.events.local_event_by_id(id).is_none() |
| 909 | | - && id.starts_with("microsoft:") |
| 910 | | - && let Some(microsoft) = &mut self.microsoft |
| 994 | + && let Some(provider_id) = event_id_provider(id) |
| 911 | 995 | { |
| 912 | | - let http = ReqwestMicrosoftHttpClient; |
| 913 | | - let token_store = KeyringMicrosoftTokenStore; |
| 914 | | - return microsoft |
| 915 | | - .delete_event(id, &http, &token_store) |
| 916 | | - .map_err(provider_error); |
| 996 | + if provider_id == "microsoft" |
| 997 | + && let Some(microsoft) = &mut self.microsoft |
| 998 | + { |
| 999 | + let http = ReqwestMicrosoftHttpClient; |
| 1000 | + let token_store = KeyringMicrosoftTokenStore; |
| 1001 | + return microsoft |
| 1002 | + .delete_event(id, &http, &token_store) |
| 1003 | + .map_err(provider_error); |
| 1004 | + } |
| 1005 | + return Err(provider_not_configured(provider_id)); |
| 917 | 1006 | } |
| 918 | 1007 | |
| 919 | 1008 | let mut events = self.events.events().to_vec(); |
@@ -934,14 +1023,18 @@ impl ConfiguredAgendaSource { |
| 934 | 1023 | |
| 935 | 1024 | pub fn duplicate_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> { |
| 936 | 1025 | if self.events.local_event_by_id(id).is_none() |
| 937 | | - && id.starts_with("microsoft:") |
| 938 | | - && let Some(microsoft) = &mut self.microsoft |
| 1026 | + && let Some(provider_id) = event_id_provider(id) |
| 939 | 1027 | { |
| 940 | | - let http = ReqwestMicrosoftHttpClient; |
| 941 | | - let token_store = KeyringMicrosoftTokenStore; |
| 942 | | - return microsoft |
| 943 | | - .duplicate_event(id, &http, &token_store) |
| 944 | | - .map_err(provider_error); |
| 1028 | + if provider_id == "microsoft" |
| 1029 | + && let Some(microsoft) = &mut self.microsoft |
| 1030 | + { |
| 1031 | + let http = ReqwestMicrosoftHttpClient; |
| 1032 | + let token_store = KeyringMicrosoftTokenStore; |
| 1033 | + return microsoft |
| 1034 | + .duplicate_event(id, &http, &token_store) |
| 1035 | + .map_err(provider_error); |
| 1036 | + } |
| 1037 | + return Err(provider_not_configured(provider_id)); |
| 945 | 1038 | } |
| 946 | 1039 | |
| 947 | 1040 | let event = self |
@@ -957,14 +1050,18 @@ impl ConfiguredAgendaSource { |
| 957 | 1050 | anchor: OccurrenceAnchor, |
| 958 | 1051 | ) -> Result<Event, LocalEventStoreError> { |
| 959 | 1052 | if self.events.local_event_by_id(series_id).is_none() |
| 960 | | - && series_id.starts_with("microsoft:") |
| 961 | | - && let Some(microsoft) = &mut self.microsoft |
| 1053 | + && let Some(provider_id) = event_id_provider(series_id) |
| 962 | 1054 | { |
| 963 | | - let http = ReqwestMicrosoftHttpClient; |
| 964 | | - let token_store = KeyringMicrosoftTokenStore; |
| 965 | | - return microsoft |
| 966 | | - .duplicate_occurrence(series_id, anchor, &http, &token_store) |
| 967 | | - .map_err(provider_error); |
| 1055 | + if provider_id == "microsoft" |
| 1056 | + && let Some(microsoft) = &mut self.microsoft |
| 1057 | + { |
| 1058 | + let http = ReqwestMicrosoftHttpClient; |
| 1059 | + let token_store = KeyringMicrosoftTokenStore; |
| 1060 | + return microsoft |
| 1061 | + .duplicate_occurrence(series_id, anchor, &http, &token_store) |
| 1062 | + .map_err(provider_error); |
| 1063 | + } |
| 1064 | + return Err(provider_not_configured(provider_id)); |
| 968 | 1065 | } |
| 969 | 1066 | |
| 970 | 1067 | let series = self.events.local_event_by_id(series_id).ok_or_else(|| { |
@@ -996,14 +1093,18 @@ impl ConfiguredAgendaSource { |
| 996 | 1093 | anchor: OccurrenceAnchor, |
| 997 | 1094 | ) -> Result<(), LocalEventStoreError> { |
| 998 | 1095 | if self.events.local_event_by_id(series_id).is_none() |
| 999 | | - && series_id.starts_with("microsoft:") |
| 1000 | | - && let Some(microsoft) = &mut self.microsoft |
| 1096 | + && let Some(provider_id) = event_id_provider(series_id) |
| 1001 | 1097 | { |
| 1002 | | - let http = ReqwestMicrosoftHttpClient; |
| 1003 | | - let token_store = KeyringMicrosoftTokenStore; |
| 1004 | | - return microsoft |
| 1005 | | - .delete_occurrence(series_id, anchor, &http, &token_store) |
| 1006 | | - .map_err(provider_error); |
| 1098 | + if provider_id == "microsoft" |
| 1099 | + && let Some(microsoft) = &mut self.microsoft |
| 1100 | + { |
| 1101 | + let http = ReqwestMicrosoftHttpClient; |
| 1102 | + let token_store = KeyringMicrosoftTokenStore; |
| 1103 | + return microsoft |
| 1104 | + .delete_occurrence(series_id, anchor, &http, &token_store) |
| 1105 | + .map_err(provider_error); |
| 1106 | + } |
| 1107 | + return Err(provider_not_configured(provider_id)); |
| 1007 | 1108 | } |
| 1008 | 1109 | |
| 1009 | 1110 | let mut events = self.events.events().to_vec(); |
@@ -1137,6 +1238,18 @@ fn provider_error(err: ProviderError) -> LocalEventStoreError { |
| 1137 | 1238 | } |
| 1138 | 1239 | } |
| 1139 | 1240 | |
| 1241 | +fn provider_not_configured(provider_id: &str) -> LocalEventStoreError { |
| 1242 | + LocalEventStoreError::Provider { |
| 1243 | + reason: format!("provider '{provider_id}' is not configured"), |
| 1244 | + } |
| 1245 | +} |
| 1246 | + |
| 1247 | +fn event_id_provider(id: &str) -> Option<&str> { |
| 1248 | + id.split_once(':') |
| 1249 | + .map(|(provider, _)| provider) |
| 1250 | + .filter(|provider| !provider.is_empty()) |
| 1251 | +} |
| 1252 | + |
| 1140 | 1253 | #[derive(Debug)] |
| 1141 | 1254 | pub enum HolidayProvider { |
| 1142 | 1255 | Off(EmptyAgendaSource), |
@@ -2989,6 +3102,74 @@ mod tests { |
| 2989 | 3102 | Event::timed(id, title, start, end, source()).expect("valid timed event") |
| 2990 | 3103 | } |
| 2991 | 3104 | |
| 3105 | + #[test] |
| 3106 | + fn event_write_target_ids_support_provider_calendar_identity() { |
| 3107 | + let target = EventWriteTargetId::provider("google", "personal", "primary"); |
| 3108 | + |
| 3109 | + assert!(!target.is_local()); |
| 3110 | + assert!(target.is_provider("google")); |
| 3111 | + assert!(!target.is_microsoft()); |
| 3112 | + assert_eq!(target.provider_id(), Some("google")); |
| 3113 | + assert_eq!(target.account_id(), Some("personal")); |
| 3114 | + assert_eq!(target.calendar_id(), Some("primary")); |
| 3115 | + assert_eq!( |
| 3116 | + target.source_id().as_deref(), |
| 3117 | + Some("google:personal:primary") |
| 3118 | + ); |
| 3119 | + } |
| 3120 | + |
| 3121 | + #[test] |
| 3122 | + fn event_write_target_ids_are_recovered_from_provider_source_metadata() { |
| 3123 | + let day = date(23); |
| 3124 | + let event = Event::timed( |
| 3125 | + "google:personal:primary:event-1", |
| 3126 | + "Planning", |
| 3127 | + at(day, 9, 0), |
| 3128 | + at(day, 10, 0), |
| 3129 | + SourceMetadata::new("google:personal:primary", "Google personal: Primary"), |
| 3130 | + ) |
| 3131 | + .expect("valid provider event"); |
| 3132 | + |
| 3133 | + assert_eq!( |
| 3134 | + EventWriteTargetId::from_event(&event), |
| 3135 | + Some(EventWriteTargetId::provider( |
| 3136 | + "google", "personal", "primary" |
| 3137 | + )) |
| 3138 | + ); |
| 3139 | + assert!(event.is_provider_backed()); |
| 3140 | + assert!(event.is_editable()); |
| 3141 | + } |
| 3142 | + |
| 3143 | + #[test] |
| 3144 | + fn configured_source_reports_unknown_provider_targets_clearly() { |
| 3145 | + let day = date(23); |
| 3146 | + let mut source = |
| 3147 | + ConfiguredAgendaSource::new(InMemoryAgendaSource::new(), HolidayProvider::off()); |
| 3148 | + let draft = CreateEventDraft { |
| 3149 | + title: "Planning".to_string(), |
| 3150 | + timing: CreateEventTiming::Timed { |
| 3151 | + start: at(day, 9, 0), |
| 3152 | + end: at(day, 10, 0), |
| 3153 | + }, |
| 3154 | + location: None, |
| 3155 | + notes: None, |
| 3156 | + reminders: Vec::new(), |
| 3157 | + recurrence: None, |
| 3158 | + }; |
| 3159 | + |
| 3160 | + let err = source |
| 3161 | + .create_event_with_target( |
| 3162 | + draft, |
| 3163 | + &EventWriteTargetId::provider("google", "personal", "primary"), |
| 3164 | + ) |
| 3165 | + .expect_err("unknown provider target fails"); |
| 3166 | + |
| 3167 | + assert!( |
| 3168 | + err.to_string() |
| 3169 | + .contains("provider 'google' is not configured") |
| 3170 | + ); |
| 3171 | + } |
| 3172 | + |
| 2992 | 3173 | #[test] |
| 2993 | 3174 | fn agenda_construction_handles_empty_days() { |
| 2994 | 3175 | let day = date(23); |