Add the iCalendar round-trip
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.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
//! `VTIMEZONE` — RFC 5545 §3.6.5.
|
||||
//!
|
||||
//! Carrying these is not optional book-keeping. A published Exchange feed names
|
||||
//! its zones `Pacific Standard Time`, not `America/Los_Angeles`: those are
|
||||
//! Windows zone names, and no IANA database will resolve them. What *does*
|
||||
//! resolve them is the `VTIMEZONE` block the same file ships alongside the
|
||||
//! events, which states the offsets and the transition rules outright.
|
||||
//!
|
||||
//! So the rule is: keep the zone identifier exactly as written — normalising it
|
||||
//! at parse time would make a round-trip impossible — and keep the definition
|
||||
//! that explains it. Resolving one to a real offset is the `recurrence`
|
||||
//! feature's job, and it has three things to try in order: the identifier as
|
||||
//! IANA, the identifier through a Windows→IANA mapping, and failing both, these
|
||||
//! rules.
|
||||
|
||||
use super::property::UnknownProperty;
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A time zone definition, as carried inside a `VCALENDAR`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VTimeZone {
|
||||
/// `TZID`, verbatim. May be an IANA name, a Windows name, or anything else
|
||||
/// a producer chose.
|
||||
pub tzid: String,
|
||||
/// `TZURL`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// The `STANDARD` and `DAYLIGHT` sub-components, in the order given.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<TimeZoneRule>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub unknown_properties: Vec<UnknownProperty>,
|
||||
}
|
||||
|
||||
impl VTimeZone {
|
||||
pub fn new(tzid: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tzid: tzid.into(),
|
||||
url: None,
|
||||
rules: Vec::new(),
|
||||
unknown_properties: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the identifier looks like a Windows zone name rather than an
|
||||
/// IANA one. IANA identifiers are `Area/Location`; Windows ones are English
|
||||
/// prose. Cheap heuristic, used only to decide which lookup to try first.
|
||||
pub fn has_windows_style_id(&self) -> bool {
|
||||
!self.tzid.contains('/') && self.tzid.contains(' ')
|
||||
}
|
||||
}
|
||||
|
||||
/// One `STANDARD` or `DAYLIGHT` block: an offset, and when it applies.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TimeZoneRule {
|
||||
pub kind: TimeZoneRuleKind,
|
||||
/// `DTSTART`, always a local time with no zone of its own — it is expressed
|
||||
/// in the offset given by `offset_from`. Exchange writes `16010101T020000`
|
||||
/// here, so a parser that range-checks years will reject real data.
|
||||
pub dtstart: NaiveDateTime,
|
||||
/// `TZOFFSETFROM` — the offset in force before this transition.
|
||||
pub offset_from: UtcOffset,
|
||||
/// `TZOFFSETTO` — the offset in force after it.
|
||||
pub offset_to: UtcOffset,
|
||||
/// `RRULE` describing when this rule recurs, verbatim.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rrule: Option<String>,
|
||||
/// `RDATE` — explicit transition times.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rdate: Vec<NaiveDateTime>,
|
||||
/// `TZNAME` — the abbreviation, such as `MST`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub names: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub unknown_properties: Vec<UnknownProperty>,
|
||||
}
|
||||
|
||||
/// Which half of a zone definition a rule describes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum TimeZoneRuleKind {
|
||||
Standard,
|
||||
Daylight,
|
||||
}
|
||||
|
||||
impl TimeZoneRuleKind {
|
||||
pub fn component_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Standard => "STANDARD",
|
||||
Self::Daylight => "DAYLIGHT",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed offset from UTC, as `TZOFFSETFROM` / `TZOFFSETTO` express it.
|
||||
///
|
||||
/// Stored in seconds. This is a raw offset and is *not* a time zone — the
|
||||
/// distinction the previous iteration collapsed, sending `-06:00` as though it
|
||||
/// identified a zone, so a recurring 9am meeting drifted an hour every spring.
|
||||
/// An offset is only ever meaningful attached to the rule that says when it
|
||||
/// applies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct UtcOffset {
|
||||
seconds: i32,
|
||||
}
|
||||
|
||||
impl UtcOffset {
|
||||
/// No offset at all.
|
||||
pub const UTC: Self = Self { seconds: 0 };
|
||||
|
||||
/// Seconds east of UTC. Rejects anything beyond ±24 hours.
|
||||
pub fn from_seconds(seconds: i32) -> Result<Self, InvalidUtcOffset> {
|
||||
if seconds.abs() > 24 * 3600 {
|
||||
return Err(InvalidUtcOffset(seconds.to_string()));
|
||||
}
|
||||
Ok(Self { seconds })
|
||||
}
|
||||
|
||||
pub fn seconds(self) -> i32 {
|
||||
self.seconds
|
||||
}
|
||||
|
||||
/// Parses the `±HHMM` or `±HHMMSS` form RFC 5545 §3.3.14 defines.
|
||||
pub fn parse(text: &str) -> Result<Self, InvalidUtcOffset> {
|
||||
let err = || InvalidUtcOffset(text.to_owned());
|
||||
let (sign, digits) = match text.as_bytes().first() {
|
||||
Some(b'+') => (1, &text[1..]),
|
||||
Some(b'-') => (-1, &text[1..]),
|
||||
_ => return Err(err()),
|
||||
};
|
||||
if !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(err());
|
||||
}
|
||||
let part = |from: usize, to: usize| digits[from..to].parse::<i32>().map_err(|_| err());
|
||||
let seconds = match digits.len() {
|
||||
4 => part(0, 2)? * 3600 + part(2, 4)? * 60,
|
||||
6 => part(0, 2)? * 3600 + part(2, 4)? * 60 + part(4, 6)?,
|
||||
_ => return Err(err()),
|
||||
};
|
||||
Self::from_seconds(sign * seconds)
|
||||
}
|
||||
|
||||
/// Renders the `±HHMM` form, widening to `±HHMMSS` only when the offset has
|
||||
/// a seconds component — which historical zones such as `Europe/Amsterdam`
|
||||
/// before 1937 genuinely do.
|
||||
pub fn to_ical(self) -> String {
|
||||
let sign = if self.seconds < 0 { '-' } else { '+' };
|
||||
let total = self.seconds.unsigned_abs();
|
||||
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
|
||||
if s == 0 {
|
||||
format!("{sign}{h:02}{m:02}")
|
||||
} else {
|
||||
format!("{sign}{h:02}{m:02}{s:02}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("not a valid UTC offset: {0:?}")]
|
||||
pub struct InvalidUtcOffset(pub String);
|
||||
Reference in New Issue
Block a user