Sync WebCal and CalDAV directly; add a skip_past cutoff
WebCal is calcalist's own (src/webcal.rs), with Calendar::split_by_uid to turn one feed document into one file per UID. Shared crate::http and crate::secret came out of google/ so every leg uses them. CalDAV follows, behind a per-endpoint 'native' flag so it can run beside pimsync one collection at a time: src/caldav/client.rs speaks PROPFIND, sync-collection, calendar-multiget and conditional PUT/DELETE, and src/caldav/mod.rs is the pull/push pair. Radicale answers an initial sync-collection with no members, so a cursor is only stored alongside a complete read. doctor reports what a server advertises. skip_past (src/horizon.rs) takes a date, not a flag: a rolling 'before now' would retire events from the aggregate as they aged, deleting yesterday's events on every cycle. Pinned to a date, what is mirrored stays mirrored. On the real configuration it leaves 2600 of 3282 events behind. The filter sits above the prior-links lookup in mirror_source, which is what makes it safe: from there DeleteSource is unreachable, so excluding an event can never be read as the user having deleted its mirror. pimsync: CWD pinned so vstorage's relative unlink cannot reach the user's files; the dry-run workspace is removed by Drop rather than by a line every '?' stepped over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d03748472a
commit
622a71dcd6
28 changed files with 2717 additions and 188 deletions
223
src/webcal.rs
Normal file
223
src/webcal.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue