1use std::borrow::Cow;
13use std::collections::{BTreeMap, HashSet};
14
15use serde::{Deserialize, Serialize};
16use url::Url;
17
18use crate::client::Response;
19use crate::error::Error;
20use crate::generated::services::calendars::Calendars;
21use crate::generated::types::{Calendar, Recording};
22use crate::http::Method;
23use crate::observability::OperationInfo;
24use crate::operation::Operation;
25use crate::pagination::next_link;
26use crate::security::is_same_origin;
27use crate::services::calendars::ListedCalendar;
28use crate::types::DateTime;
29
30const TOO_FAR_BEHIND: u16 = 409;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CalendarChangesCursor {
44 pub since: Option<String>,
46 pub version: Option<String>,
48 pub page: Option<String>,
50 pub per_page: Option<String>,
52}
53
54impl CalendarChangesCursor {
55 pub fn from_url(changes_url: &str) -> Result<CalendarChangesCursor, Error> {
59 let url = Url::parse(changes_url)
60 .map_err(|error| Error::usage(format!("changes URL {changes_url}: {error}")))?;
61 Ok(CalendarChangesCursor::from_parsed(&url))
62 }
63
64 fn from_parsed(url: &Url) -> CalendarChangesCursor {
65 CalendarChangesCursor {
66 since: parameter(url, "since"),
67 version: parameter(url, "v"),
68 page: parameter(url, "page"),
69 per_page: parameter(url, "per_page"),
70 }
71 }
72
73 fn apply(&self, operation: &mut Operation) {
76 operation.query_optional("since", self.since.as_ref());
77 operation.query_optional("v", self.version.as_ref());
78 operation.query_optional("page", self.page.as_ref());
79 operation.query_optional("per_page", self.per_page.as_ref());
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[non_exhaustive]
86pub struct DeletedCalendar {
87 #[serde(default)]
89 pub id: i64,
90 pub deleted_at: DateTime,
92}
93
94#[derive(Debug, Clone, Default, PartialEq)]
103#[non_exhaustive]
104pub struct CalendarChanges {
105 pub added: Vec<ListedCalendar>,
107 pub updated: Vec<Calendar>,
109 pub deleted: Vec<DeletedCalendar>,
111 pub next_page: Option<CalendarChangesCursor>,
113 pub next_cursor: Option<CalendarChangesCursor>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct DeletedRecording {
122 #[serde(default)]
124 pub id: i64,
125 pub deleted_at: DateTime,
127 #[serde(default)]
129 pub r#type: String,
130}
131
132#[derive(Debug, Clone, Default, PartialEq)]
149#[non_exhaustive]
150pub struct RecordingChanges {
151 pub added: BTreeMap<String, Vec<Recording>>,
153 pub updated: BTreeMap<String, Vec<Recording>>,
155 pub deleted: Vec<DeletedRecording>,
157 pub next_page: Option<CalendarChangesCursor>,
159 pub next_cursor: Option<CalendarChangesCursor>,
161 pub full_sync_required: bool,
163}
164
165impl Calendars<'_> {
166 pub async fn all_calendar_changes(
169 &self,
170 cursor: &CalendarChangesCursor,
171 ) -> Result<CalendarChanges, Error> {
172 let mut all = CalendarChanges::default();
173 let mut cursor = cursor.clone();
174
175 for _ in 0..self.client().max_pages() {
176 let mut changes = self.calendar_changes(&cursor).await?;
177 all.added.append(&mut changes.added);
178 all.updated.append(&mut changes.updated);
179 all.deleted.append(&mut changes.deleted);
180 all.next_cursor = changes.next_cursor;
181
182 match changes.next_page {
183 None => return Ok(all),
184 Some(next) => cursor = next,
185 }
186 }
187
188 crate::trace::warning!(
189 max_pages = self.client().max_pages(),
190 "calendar changes pagination capped"
191 );
192 Ok(all)
193 }
194
195 pub async fn calendar_changes(
197 &self,
198 cursor: &CalendarChangesCursor,
199 ) -> Result<CalendarChanges, Error> {
200 if cursor.since.is_none() {
201 return Err(Error::usage(
202 "a since cursor is required — start from the list's calendar_changes_url",
203 ));
204 }
205
206 let mut operation = self.client().request(Method::GET, "/calendar/changes");
207 operation.info(changes_info("GetCalendarChanges", "calendar"));
208 cursor.apply(&mut operation);
209 operation.no_cache();
210
211 let response = self.client().execute(operation).await?;
212 let payload: CalendarChangesPayload = response.json()?;
213 let (next_page, next_cursor) = next_cursors(&response, self.client().base_url())?;
214 Ok(CalendarChanges {
215 added: payload.added,
216 updated: payload.updated,
217 deleted: payload.deleted,
218 next_page,
219 next_cursor,
220 })
221 }
222
223 pub async fn all_recording_changes(
227 &self,
228 calendar_id: i64,
229 cursor: &CalendarChangesCursor,
230 ) -> Result<RecordingChanges, Error> {
231 let mut all = RecordingChanges::default();
232 let mut cursor = cursor.clone();
233
234 for _ in 0..self.client().max_pages() {
235 let mut changes = self.recording_changes(calendar_id, &cursor).await?;
236 if changes.full_sync_required {
237 return Ok(changes);
238 }
239
240 merge_recordings(&mut all.added, changes.added);
241 merge_recordings(&mut all.updated, changes.updated);
242 all.deleted.append(&mut changes.deleted);
243 all.next_cursor = changes.next_cursor;
244
245 match changes.next_page {
246 None => return Ok(all),
247 Some(next) => cursor = next,
248 }
249 }
250
251 crate::trace::warning!(
252 max_pages = self.client().max_pages(),
253 "recording changes pagination capped"
254 );
255 Ok(all)
256 }
257
258 pub async fn recording_changes(
260 &self,
261 calendar_id: i64,
262 cursor: &CalendarChangesCursor,
263 ) -> Result<RecordingChanges, Error> {
264 if cursor.since.is_none() {
265 return Err(Error::usage(
266 "a since cursor is required — start from the calendar's recording_changes_url",
267 ));
268 }
269 if cursor.version.is_none() {
270 return Err(Error::usage(
271 "a feed version is required — read the calendar's recording_changes_url with CalendarChangesCursor::from_url",
272 ));
273 }
274
275 let mut operation = self.client().request(
276 Method::GET,
277 format!("/calendars/{calendar_id}/recording/changes"),
278 );
279 operation.info(changes_info("GetCalendarRecordingChanges", "recording"));
280 operation.resource_id(calendar_id);
281 cursor.apply(&mut operation);
282 operation.no_cache();
283
284 let response = match self.client().execute(operation).await {
285 Ok(response) => response,
286 Err(error) if error.http_status() == Some(TOO_FAR_BEHIND) => {
287 return Ok(RecordingChanges {
288 full_sync_required: true,
289 ..RecordingChanges::default()
290 });
291 }
292 Err(error) => return Err(error),
293 };
294
295 let payload: RecordingChangesPayload = response.json()?;
296 let (next_page, next_cursor) = next_cursors(&response, self.client().base_url())?;
297 Ok(RecordingChanges {
298 added: payload.added,
299 updated: payload.updated,
300 deleted: flatten_deleted_recordings(payload.deleted),
301 next_page,
302 next_cursor,
303 full_sync_required: false,
304 })
305 }
306}
307
308#[derive(Debug, Default, Deserialize)]
309struct CalendarChangesPayload {
310 #[serde(default)]
311 added: Vec<ListedCalendar>,
312 #[serde(default)]
313 updated: Vec<Calendar>,
314 #[serde(default)]
315 deleted: Vec<DeletedCalendar>,
316}
317
318#[derive(Debug, Default, Deserialize)]
319struct RecordingChangesPayload {
320 #[serde(default)]
321 added: BTreeMap<String, Vec<Recording>>,
322 #[serde(default)]
323 updated: BTreeMap<String, Vec<Recording>>,
324 #[serde(default)]
325 deleted: BTreeMap<String, Vec<DeletedRecording>>,
326}
327
328fn changes_info(operation: &'static str, resource_type: &'static str) -> OperationInfo {
329 OperationInfo {
330 service: Cow::Borrowed("Calendars"),
331 operation: Cow::Borrowed(operation),
332 resource_type: Cow::Borrowed(resource_type),
333 is_mutation: false,
334 resource_id: None,
335 }
336}
337
338fn next_cursors(
342 response: &Response,
343 base_url: &Url,
344) -> Result<(Option<CalendarChangesCursor>, Option<CalendarChangesCursor>), Error> {
345 match link_cursor(response, base_url)? {
346 None => Ok((None, None)),
347 Some(cursor) if cursor.page.is_some() => Ok((Some(cursor), None)),
348 Some(cursor) => Ok((None, Some(cursor))),
349 }
350}
351
352fn link_cursor(
353 response: &Response,
354 base_url: &Url,
355) -> Result<Option<CalendarChangesCursor>, Error> {
356 match response.header("link").and_then(next_link) {
357 None => Ok(None),
358 Some(target) => {
359 let next = response.url.join(&target)?;
360 if is_same_origin(&next, base_url) {
361 Ok(Some(CalendarChangesCursor::from_parsed(&next)))
362 } else {
363 Err(Error::usage(format!(
364 "changes Link header points to a different origin: {next}"
365 )))
366 }
367 }
368 }
369}
370
371fn merge_recordings(
372 into: &mut BTreeMap<String, Vec<Recording>>,
373 from: BTreeMap<String, Vec<Recording>>,
374) {
375 for (key, recordings) in from {
376 into.entry(key).or_default().extend(recordings);
377 }
378}
379
380fn flatten_deleted_recordings(
384 buckets: BTreeMap<String, Vec<DeletedRecording>>,
385) -> Vec<DeletedRecording> {
386 let mut seen = HashSet::new();
387 buckets
388 .into_values()
389 .flatten()
390 .filter(|record| seen.insert(record.id))
391 .collect()
392}
393
394fn parameter(url: &Url, name: &str) -> Option<String> {
395 url.query_pairs()
396 .find(|(key, _)| key == name)
397 .map(|(_, value)| value.into_owned())
398 .filter(|value| !value.is_empty())
399}