Parsing goes through icalendar's low-level parser, which keeps properties in order and keeps repeated ones. Writing is ours: that crate's writer escapes a whole property value as text, so CATEGORIES:Work,Personal would go out as one category named "Work,Personal" to every other client. Anything the model does not interpret is carried rather than dropped -- X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL components. A calendar has several clients writing to it and this one is not the authority on which properties matter. VTIMEZONE is modelled properly, and TZID is stored exactly as written: Exchange names its zones "Pacific Standard Time", which no IANA lookup resolves, and normalising at parse time would make the document unrepresentable. Mapping to a real zone belongs at the point of use. Tested against a golden corpus captured from the live Baikal (seven producing clients over five years) and a published Outlook feed, scrubbed of private content with the structure left byte-for-byte. Eight hand-written fixtures cover what neither server had: DURATION, floating times, RDATE, DST boundaries, leap day, and the full escape set. The contract is that parse -> write -> parse is stable, plus a check that no property name loses occurrences across the trip, since a parser that dropped ATTENDEE entirely would round-trip perfectly and still be wrong.
92 lines
3.8 KiB
Rust
92 lines
3.8 KiB
Rust
//! A CalDAV calendar object resource — one `.ics` file on the server.
|
|
//!
|
|
//! This is the level at which CalDAV actually operates, and modelling it
|
|
//! explicitly is what makes correct recurrence handling possible.
|
|
//!
|
|
//! A single resource holds a `VCALENDAR` containing one or more `VEVENT`s that
|
|
//! all share a `UID`: the series master, plus one override per modified
|
|
//! occurrence, each distinguished by its `RECURRENCE-ID`. The previous
|
|
//! iteration flattened that structure into a bare list of events, which made
|
|
//! masters and overrides indistinguishable — so a correctly-formed feed looked
|
|
//! like it contained duplicates, and ~500 lines of title-matching heuristics
|
|
//! were added to "clean up" the result by silently discarding events. Keeping
|
|
//! the resource intact removes the need for any of that.
|
|
|
|
use super::calendar::VCalendar;
|
|
use super::event::VEvent;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// One addressable resource on a CalDAV server, with its concurrency metadata.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CalendarObject {
|
|
/// Path of this resource on the server, relative to its host.
|
|
pub href: String,
|
|
/// Server-assigned `ETag`. Absent for an object not yet written.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub etag: Option<String>,
|
|
/// Path of the collection holding this resource.
|
|
pub calendar_path: String,
|
|
/// The `VCALENDAR` this resource contains — events, and the zone
|
|
/// definitions they reference. Both halves have to travel together: a `PUT`
|
|
/// that dropped the `VTIMEZONE` would leave the resource unreadable to
|
|
/// clients that cannot resolve the identifier on their own.
|
|
pub calendar: VCalendar,
|
|
}
|
|
|
|
impl CalendarObject {
|
|
/// The `VEVENT`s inside, sharing one `UID`: at most one master plus any
|
|
/// number of overrides.
|
|
pub fn events(&self) -> &[VEvent] {
|
|
&self.calendar.events
|
|
}
|
|
|
|
/// The series master — the event without a `RECURRENCE-ID`.
|
|
///
|
|
/// Absent when a resource contains only overrides, which is unusual but
|
|
/// legal and which servers do emit.
|
|
pub fn master(&self) -> Option<&VEvent> {
|
|
self.calendar.master()
|
|
}
|
|
|
|
/// The overrides, each replacing one occurrence of the master.
|
|
pub fn overrides(&self) -> impl Iterator<Item = &VEvent> {
|
|
self.calendar.overrides()
|
|
}
|
|
|
|
/// The `UID` shared by every event in this resource.
|
|
pub fn uid(&self) -> Option<&str> {
|
|
self.calendar.events.first().map(|e| e.uid.as_str())
|
|
}
|
|
|
|
/// Whether every contained event agrees on the `UID`, as RFC 4791 requires.
|
|
///
|
|
/// Worth asserting in tests against real servers rather than assuming.
|
|
pub fn has_consistent_uid(&self) -> bool {
|
|
let mut uids = self.calendar.events.iter().map(|e| e.uid.as_str());
|
|
match uids.next() {
|
|
None => true,
|
|
Some(first) => uids.all(|u| u == first),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which occurrences of a series an edit or deletion applies to.
|
|
///
|
|
/// Replaces the previous iteration's stringly-typed dispatch on values like
|
|
/// `"this_only"`, `"this_and_future"` and `"delete_series"`, which appeared 53
|
|
/// times across the codebase and turned a typo into a silent runtime
|
|
/// fallthrough rather than a compile error.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum EditScope {
|
|
/// Just the occurrence named by the request. Realised as an `EXDATE` on the
|
|
/// master plus a new override carrying a `RECURRENCE-ID`.
|
|
#[default]
|
|
ThisOnly,
|
|
/// This occurrence and everything after it. Realised by truncating the
|
|
/// master with `UNTIL` and creating a fresh series.
|
|
ThisAndFuture,
|
|
/// Every occurrence. Realised by editing the master in place.
|
|
EntireSeries,
|
|
}
|