CalCalist/src/webcal.rs

224 lines
8.1 KiB
Rust
Raw Normal View History

//! 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<ureq::Error>,
},
#[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<PullReport, WebcalError> {
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<String, WebcalError> {
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<String, WebcalError> {
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<String, Calendar> {
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::<Vec<_>>(), ["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:?}");
}
}