//! The WebCal (published iCal feed) leg. //! //! A feed is a single iCalendar document fetched over HTTP, read-only by //! protocol: there is nothing to push and no conflict to resolve. That makes the //! whole leg a download, a split by UID and a vdir write, which is why calcalist //! does it itself rather than through pimsync — a subprocess, a generated config //! file and a status database bought nothing that this module does not. //! //! The fetch is unconditional. Feeds are small, publishers rarely honour //! `If-Modified-Since`, and the reconciler already compares content hashes, so a //! re-read of unchanged events costs a download and changes nothing on disk. use std::path::Path; use thiserror::Error; use crate::ical::{Calendar, IcalError}; use crate::secret::SecretError; use crate::vdir::{self, VdirError}; #[derive(Debug, Error)] pub enum WebcalError { #[error("endpoint `{0}` has neither url nor url_command")] NoUrl(String), #[error(transparent)] Secret(#[from] SecretError), #[error("could not fetch {url}: {source}")] Fetch { url: String, #[source] source: Box, }, #[error("{url} answered {status}")] Status { url: String, status: u16 }, #[error("{url} did not return a readable calendar: {source}")] Malformed { url: String, #[source] source: IcalError, }, #[error(transparent)] Vdir(#[from] VdirError), } #[derive(Debug, Default)] pub struct PullReport { pub written: usize, pub deleted: usize, } /// Brings an endpoint's local vdir into step with its feed. /// /// The feed is the whole truth every time — it is one document describing the /// calendar in full — so an item held locally that the feed no longer mentions /// has been withdrawn, and goes. pub fn pull(url: &str, vdir_dir: &Path) -> Result { let feed = fetch(url)?; let calendar = Calendar::parse(&feed).map_err(|source| WebcalError::Malformed { url: url.to_string(), source, })?; let events = calendar.split_by_uid(); let held = vdir::read(vdir_dir)?; let mut report = PullReport::default(); for (uid, event) in &events { // Unchanged events are left alone: rewriting them would churn every // file on every cycle, and the reconciler reads content, not mtimes. let current = held.get(uid); if current.is_some_and(|item| item.hash == event.content_hash()) { continue; } vdir::write( vdir_dir, uid, event, current.map(|item| item.path.as_path()), )?; report.written += 1; } for (uid, item) in &held { if !events.contains_key(uid) { vdir::remove(&item.path)?; report.deleted += 1; } } Ok(report) } /// The feed's address, which may itself be the credential. /// /// Google's "secret address in iCal format" grants read access to the whole /// calendar to anyone holding it, so such a URL is fetched by command and never /// written into the configuration file. pub fn resolve_url( endpoint: &str, url: Option<&str>, url_command: Option<&str>, ) -> Result { match (url, url_command) { (_, Some(command)) => Ok(crate::secret::run(command)?), (Some(url), None) => Ok(url.to_string()), (None, None) => Err(WebcalError::NoUrl(endpoint.to_string())), } } fn fetch(url: &str) -> Result { let mut response = crate::http::agent() .get(url) .call() .map_err(|source| WebcalError::Fetch { url: url.to_string(), source: Box::new(source), })?; let status = response.status().as_u16(); if !(200..300).contains(&status) { return Err(WebcalError::Status { url: url.to_string(), status, }); } response .body_mut() .read_to_string() .map_err(|source| WebcalError::Fetch { url: url.to_string(), source: Box::new(source), }) } #[cfg(test)] mod tests { use super::*; const FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n\ BEGIN:VEVENT\r\nUID:one@example.com\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;VALUE=DATE:20260101\r\nSUMMARY:New Year\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:two@example.com\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;VALUE=DATE:20260501\r\nSUMMARY:May Day\r\nEND:VEVENT\r\n\ END:VCALENDAR\r\n"; /// A recurring event and one overridden occurrence share a UID, so they have /// to land in one file; splitting them would give the vdir two events where /// the calendar has one. const SERIES: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n\ BEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\n\ TZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\ BEGIN:VTIMEZONE\r\nTZID:America/New_York\r\n\ BEGIN:STANDARD\r\nDTSTART:19701101T020000\r\nTZOFFSETFROM:-0400\r\n\ TZOFFSETTO:-0500\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\ BEGIN:VEVENT\r\nUID:series@example.com\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;TZID=Europe/Berlin:20260105T090000\r\nRRULE:FREQ=WEEKLY\r\n\ SUMMARY:Standup\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:series@example.com\r\nDTSTAMP:20260101T000000Z\r\n\ RECURRENCE-ID;TZID=Europe/Berlin:20260112T090000\r\n\ DTSTART;TZID=Europe/Berlin:20260112T100000\r\n\ SUMMARY:Standup (late)\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; fn split(feed: &str) -> std::collections::BTreeMap { Calendar::parse(feed) .expect("the feed should parse") .split_by_uid() } #[test] fn each_event_becomes_a_calendar_of_its_own() { let events = split(FEED); assert_eq!(events.len(), 2); let one = events["one@example.com"].to_ics(); assert!(one.contains("SUMMARY:New Year"), "{one}"); assert!(!one.contains("May Day"), "{one}"); // The envelope travels, or the file is a fragment rather than a calendar. assert!(one.contains("PRODID:-//Test//EN"), "{one}"); } #[test] fn a_series_and_its_override_stay_together() { let events = split(SERIES); assert_eq!(events.len(), 1); let series = events["series@example.com"].to_ics(); assert!(series.contains("SUMMARY:Standup"), "{series}"); assert!(series.contains("SUMMARY:Standup (late)"), "{series}"); assert!(series.contains("RECURRENCE-ID"), "{series}"); } /// A time is meaningless without the zone it names, and carrying every zone /// in the feed into every file would bloat each one for nothing. #[test] fn only_the_timezones_an_event_names_come_with_it() { let events = split(SERIES); let series = events["series@example.com"].to_ics(); assert!(series.contains("TZID:Europe/Berlin"), "{series}"); assert!(!series.contains("America/New_York"), "{series}"); } #[test] fn an_event_without_a_uid_is_skipped_rather_than_failing_the_feed() { let feed = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ BEGIN:VEVENT\r\nDTSTAMP:20260101T000000Z\r\nSUMMARY:Nameless\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:kept@example.com\r\nDTSTAMP:20260101T000000Z\r\n\ SUMMARY:Kept\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let events = split(feed); assert_eq!(events.keys().collect::>(), ["kept@example.com"]); } #[test] fn a_url_command_supplies_the_address() { let resolved = resolve_url("feed", None, Some("printf https://example.com/f.ics")) .expect("the command should supply a url"); assert_eq!(resolved, "https://example.com/f.ics"); } #[test] fn an_endpoint_with_no_address_at_all_is_an_error() { let error = resolve_url("feed", None, None).expect_err("there is no url"); assert!(matches!(error, WebcalError::NoUrl(_)), "{error:?}"); } }