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:
Generated
+1
@@ -2082,6 +2082,7 @@ dependencies = [
|
|||||||
"icalendar",
|
"icalendar",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rrule",
|
"rrule",
|
||||||
|
"runway-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ uuid = { workspace = true, features = ["js"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
|
# Turns the optional features on for the test build only, so `cargo test` covers
|
||||||
|
# the iCalendar layer without the frontend's default build ever pulling it in.
|
||||||
|
runway-core = { path = ".", features = ["ical"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//! Errors from the iCalendar layer.
|
||||||
|
//!
|
||||||
|
//! These are typed rather than `String` on purpose. The previous iteration
|
||||||
|
//! returned `Result<T, String>` at nearly every boundary, so a 401 and a parse
|
||||||
|
//! failure were indistinguishable to the caller and nothing could be handled
|
||||||
|
//! differently from anything else.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum IcalError {
|
||||||
|
/// The document is not well-formed iCalendar.
|
||||||
|
#[error("malformed iCalendar: {0}")]
|
||||||
|
Syntax(String),
|
||||||
|
|
||||||
|
/// No `VCALENDAR` in the input at all.
|
||||||
|
#[error("no VCALENDAR component found")]
|
||||||
|
NoCalendar,
|
||||||
|
|
||||||
|
/// A property RFC 5545 requires is absent.
|
||||||
|
#[error("{component} is missing required property {property}")]
|
||||||
|
MissingProperty {
|
||||||
|
component: &'static str,
|
||||||
|
property: &'static str,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A property is present but its value cannot be read as its own type.
|
||||||
|
#[error("{property} has an invalid value {value:?}: {reason}")]
|
||||||
|
InvalidValue {
|
||||||
|
property: String,
|
||||||
|
value: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IcalError {
|
||||||
|
pub(crate) fn invalid(
|
||||||
|
property: impl Into<String>,
|
||||||
|
value: impl Into<String>,
|
||||||
|
reason: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::InvalidValue {
|
||||||
|
property: property.into(),
|
||||||
|
value: value.into(),
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! iCalendar round-trip — RFC 5545.
|
||||||
|
//!
|
||||||
|
//! Reading goes through `icalendar`'s low-level parser, which owns the grammar:
|
||||||
|
//! line unfolding, parameter quoting, value escaping. Writing is ours, because
|
||||||
|
//! that crate's writer escapes a whole property value as text and would corrupt
|
||||||
|
//! the separators in multi-value properties; see [`value`] for the detail.
|
||||||
|
//!
|
||||||
|
//! Two rules shape everything here, both drawn from what went wrong last time:
|
||||||
|
//!
|
||||||
|
//! 1. **Anything unrecognised is carried, not dropped.** A calendar is written
|
||||||
|
//! by several clients at once and ours is not the authority on which
|
||||||
|
//! properties matter.
|
||||||
|
//! 2. **When the output looks wrong, the parse is wrong.** No post-processing
|
||||||
|
//! pass exists to tidy up results, because there is nothing for one to fix.
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod parse;
|
||||||
|
mod value;
|
||||||
|
mod write;
|
||||||
|
|
||||||
|
pub use error::IcalError;
|
||||||
|
pub use parse::{parse, parse_all};
|
||||||
|
pub use write::write;
|
||||||
|
|
||||||
|
use crate::model::{CalendarObject, VCalendar};
|
||||||
|
|
||||||
|
/// Parses a CalDAV resource: a `VCALENDAR` plus the metadata the server
|
||||||
|
/// supplied alongside it.
|
||||||
|
pub fn parse_object(
|
||||||
|
href: impl Into<String>,
|
||||||
|
calendar_path: impl Into<String>,
|
||||||
|
etag: Option<String>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<CalendarObject, IcalError> {
|
||||||
|
Ok(CalendarObject {
|
||||||
|
href: href.into(),
|
||||||
|
etag,
|
||||||
|
calendar_path: calendar_path.into(),
|
||||||
|
calendar: parse(body)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a calendar for a `PUT`.
|
||||||
|
pub fn write_object(object: &CalendarObject) -> String {
|
||||||
|
write(&object.calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for the common case: one event, one resource.
|
||||||
|
pub fn write_event(event: &crate::model::VEvent) -> String {
|
||||||
|
write(&VCalendar::with_events(vec![event.clone()]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
//! Reading a `VCALENDAR` into the domain model.
|
||||||
|
//!
|
||||||
|
//! The grammar — folding, quoting, escaping — comes from `icalendar`'s
|
||||||
|
//! low-level parser, which hands back a component tree that keeps properties in
|
||||||
|
//! their original order and, crucially, keeps *repeated* properties. The
|
||||||
|
//! previous iteration collected properties into a `HashMap<String, String>`, so
|
||||||
|
//! the second `ATTENDEE`, the second `EXDATE` and the second `VALARM` were
|
||||||
|
//! silently discarded by the map. That single choice is upstream of both the
|
||||||
|
//! `// TODO: Parse attendees properly` and the ~500 lines of title-matching
|
||||||
|
//! heuristics that existed to tidy up the "duplicates" a broken parse produced.
|
||||||
|
|
||||||
|
use super::error::IcalError;
|
||||||
|
use super::value::{self, read_datetime, read_duration, read_utc, split_list};
|
||||||
|
use crate::model::{
|
||||||
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, CalendarUserType, EventClass, EventEnd,
|
||||||
|
EventStatus, GeoPosition, ParticipationStatus, Priority, PropertyParam, Role, TimeZoneRule,
|
||||||
|
TimeZoneRuleKind, Transparency, TriggerRelation, UnknownComponent, UnknownProperty, UtcOffset,
|
||||||
|
VAlarm, VCalendar, VEvent, VTimeZone,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
use icalendar::parser::{Component as RawComponent, Property as RawProperty};
|
||||||
|
|
||||||
|
/// Parses a document into its `VCALENDAR`.
|
||||||
|
///
|
||||||
|
/// Only the first calendar is returned; a `.ics` file legally holds one.
|
||||||
|
pub fn parse(input: &str) -> Result<VCalendar, IcalError> {
|
||||||
|
parse_all(input)?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or(IcalError::NoCalendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses every `VCALENDAR` in a document.
|
||||||
|
pub fn parse_all(input: &str) -> Result<Vec<VCalendar>, IcalError> {
|
||||||
|
let unfolded = icalendar::parser::unfold(input);
|
||||||
|
let roots = icalendar::parser::read_components(&unfolded).map_err(IcalError::Syntax)?;
|
||||||
|
roots
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.name.as_str().eq_ignore_ascii_case("VCALENDAR"))
|
||||||
|
.map(parse_calendar)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_calendar(raw: &RawComponent<'_>) -> Result<VCalendar, IcalError> {
|
||||||
|
let mut cal = VCalendar {
|
||||||
|
prodid: String::new(),
|
||||||
|
version: "2.0".to_owned(),
|
||||||
|
calscale: None,
|
||||||
|
method: None,
|
||||||
|
events: Vec::new(),
|
||||||
|
timezones: Vec::new(),
|
||||||
|
unknown_properties: Vec::new(),
|
||||||
|
unknown_components: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for prop in &raw.properties {
|
||||||
|
let p = View::of(prop);
|
||||||
|
match p.name.as_str() {
|
||||||
|
"PRODID" => cal.prodid = p.value.to_owned(),
|
||||||
|
"VERSION" => cal.version = p.value.to_owned(),
|
||||||
|
"CALSCALE" => cal.calscale = Some(p.value.to_owned()),
|
||||||
|
"METHOD" => cal.method = Some(p.value.to_owned()),
|
||||||
|
_ => cal.unknown_properties.push(p.into_unknown()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in &raw.components {
|
||||||
|
match child.name.as_str().to_uppercase().as_str() {
|
||||||
|
"VEVENT" => cal.events.push(parse_event(child)?),
|
||||||
|
"VTIMEZONE" => cal.timezones.push(parse_timezone(child)?),
|
||||||
|
_ => cal.unknown_components.push(carry_component(child)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ VEVENT --
|
||||||
|
|
||||||
|
fn parse_event(raw: &RawComponent<'_>) -> Result<VEvent, IcalError> {
|
||||||
|
let mut uid = None;
|
||||||
|
let mut dtstamp = None;
|
||||||
|
let mut dtstart = None;
|
||||||
|
let mut dtend = None;
|
||||||
|
let mut duration = None;
|
||||||
|
|
||||||
|
let missing = |property| IcalError::MissingProperty {
|
||||||
|
component: "VEVENT",
|
||||||
|
property,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Built around DTSTART, which is the one field with no sensible default.
|
||||||
|
let mut ev = VEvent::with_uid(
|
||||||
|
String::new(),
|
||||||
|
crate::model::CalendarDateTime::Utc {
|
||||||
|
utc: DateTime::<Utc>::UNIX_EPOCH,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ev.created = None;
|
||||||
|
ev.last_modified = None;
|
||||||
|
|
||||||
|
for prop in &raw.properties {
|
||||||
|
let p = View::of(prop);
|
||||||
|
match p.name.as_str() {
|
||||||
|
"UID" => uid = Some(p.value.to_owned()),
|
||||||
|
"DTSTAMP" => dtstamp = Some(read_utc("DTSTAMP", p.value)?),
|
||||||
|
"DTSTART" => dtstart = Some(p.datetime()?),
|
||||||
|
"DTEND" => dtend = Some(p.datetime()?),
|
||||||
|
"DURATION" => duration = Some(read_duration("DURATION", p.value)?),
|
||||||
|
"CREATED" => ev.created = Some(read_utc("CREATED", p.value)?),
|
||||||
|
"LAST-MODIFIED" => ev.last_modified = Some(read_utc("LAST-MODIFIED", p.value)?),
|
||||||
|
"SEQUENCE" => ev.sequence = p.value.trim().parse().unwrap_or_default(),
|
||||||
|
_ => apply_property(&mut ev, &p)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.uid = uid.ok_or_else(|| missing("UID"))?;
|
||||||
|
ev.dtstart = dtstart.ok_or_else(|| missing("DTSTART"))?;
|
||||||
|
// RFC 5545 requires DTSTAMP, but not every producer writes one. Falling
|
||||||
|
// back to the modification times keeps a usable event rather than
|
||||||
|
// rejecting the whole resource over book-keeping.
|
||||||
|
ev.dtstamp = dtstamp
|
||||||
|
.or(ev.last_modified)
|
||||||
|
.or(ev.created)
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
// §3.6.1: DTEND and DURATION are mutually exclusive. When a producer sends
|
||||||
|
// both, the explicit end wins.
|
||||||
|
ev.end = match (dtend, duration) {
|
||||||
|
(Some(dtend), _) => Some(EventEnd::DateTime { dtend }),
|
||||||
|
(None, Some(duration)) => Some(EventEnd::Duration { duration }),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in &raw.components {
|
||||||
|
if child.name.as_str().eq_ignore_ascii_case("VALARM") {
|
||||||
|
ev.alarms.push(parse_alarm(child)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything that is neither timing nor identity.
|
||||||
|
fn apply_property(ev: &mut VEvent, p: &View<'_>) -> Result<(), IcalError> {
|
||||||
|
match p.name.as_str() {
|
||||||
|
"SUMMARY" => ev.summary = Some(p.text()),
|
||||||
|
"DESCRIPTION" => ev.description = Some(p.text()),
|
||||||
|
"LOCATION" => ev.location = Some(p.text()),
|
||||||
|
"COMMENT" => ev.comment = Some(p.text()),
|
||||||
|
"CONTACT" => ev.contact = Some(p.text()),
|
||||||
|
"RELATED-TO" => ev.related_to = Some(p.text()),
|
||||||
|
"URL" => ev.url = Some(p.value.to_owned()),
|
||||||
|
"GEO" => ev.geo = Some(parse_geo(p.value)?),
|
||||||
|
"STATUS" => ev.status = parse_status(p.value),
|
||||||
|
"CLASS" => ev.class = parse_class(p.value),
|
||||||
|
"TRANSP" => ev.transparency = parse_transparency(p.value),
|
||||||
|
"PRIORITY" => {
|
||||||
|
ev.priority = p
|
||||||
|
.value
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.ok()
|
||||||
|
.and_then(|n| Priority::new(n).ok());
|
||||||
|
}
|
||||||
|
"ORGANIZER" => ev.organizer = Some(p.organizer()),
|
||||||
|
// Repeated properties are appended, never replaced. This is the whole
|
||||||
|
// reason for walking an ordered list instead of a map.
|
||||||
|
"ATTENDEE" => ev.attendees.push(p.attendee()),
|
||||||
|
"CATEGORIES" => ev.categories.extend(p.text_list()),
|
||||||
|
"RESOURCES" => ev.resources.extend(p.text_list()),
|
||||||
|
"RRULE" => ev.rrule = Some(p.value.trim().to_owned()),
|
||||||
|
"RDATE" => ev.rdate.extend(p.datetime_list()?),
|
||||||
|
"EXDATE" => ev.exdate.extend(p.datetime_list()?),
|
||||||
|
"RECURRENCE-ID" => ev.recurrence_id = Some(p.datetime()?),
|
||||||
|
_ => ev.unknown_properties.push(p.clone().into_unknown()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_geo(value: &str) -> Result<GeoPosition, IcalError> {
|
||||||
|
let (lat, lon) = value
|
||||||
|
.split_once(';')
|
||||||
|
.ok_or_else(|| IcalError::invalid("GEO", value, "expected 'latitude;longitude'"))?;
|
||||||
|
let read = |s: &str| {
|
||||||
|
s.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.map_err(|e| IcalError::invalid("GEO", value, e.to_string()))
|
||||||
|
};
|
||||||
|
Ok(GeoPosition {
|
||||||
|
latitude: read(lat)?,
|
||||||
|
longitude: read(lon)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_status(value: &str) -> Option<EventStatus> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"TENTATIVE" => Some(EventStatus::Tentative),
|
||||||
|
"CONFIRMED" => Some(EventStatus::Confirmed),
|
||||||
|
"CANCELLED" => Some(EventStatus::Cancelled),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_class(value: &str) -> Option<EventClass> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"PUBLIC" => Some(EventClass::Public),
|
||||||
|
"PRIVATE" => Some(EventClass::Private),
|
||||||
|
"CONFIDENTIAL" => Some(EventClass::Confidential),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_transparency(value: &str) -> Option<Transparency> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"OPAQUE" => Some(Transparency::Opaque),
|
||||||
|
"TRANSPARENT" => Some(Transparency::Transparent),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ VALARM --
|
||||||
|
|
||||||
|
fn parse_alarm(raw: &RawComponent<'_>) -> Result<VAlarm, IcalError> {
|
||||||
|
let mut action = None;
|
||||||
|
let mut trigger = None;
|
||||||
|
let mut alarm = VAlarm {
|
||||||
|
action: AlarmAction::Display,
|
||||||
|
trigger: AlarmTrigger::Relative {
|
||||||
|
offset: crate::model::IcalDuration::from(chrono::TimeDelta::zero()),
|
||||||
|
related: TriggerRelation::Start,
|
||||||
|
},
|
||||||
|
duration: None,
|
||||||
|
repeat: None,
|
||||||
|
description: None,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
unknown_properties: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for prop in &raw.properties {
|
||||||
|
let p = View::of(prop);
|
||||||
|
match p.name.as_str() {
|
||||||
|
"ACTION" => action = parse_action(p.value),
|
||||||
|
"TRIGGER" => trigger = Some(parse_trigger(&p)?),
|
||||||
|
"DURATION" => alarm.duration = Some(read_duration("DURATION", p.value)?),
|
||||||
|
"REPEAT" => alarm.repeat = p.value.trim().parse().ok(),
|
||||||
|
"DESCRIPTION" => alarm.description = Some(p.text()),
|
||||||
|
"SUMMARY" => alarm.summary = Some(p.text()),
|
||||||
|
"ATTENDEE" => alarm.attendees.push(p.attendee()),
|
||||||
|
_ => alarm.unknown_properties.push(p.into_unknown()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alarm.action = action.unwrap_or(AlarmAction::Display);
|
||||||
|
alarm.trigger = trigger.ok_or(IcalError::MissingProperty {
|
||||||
|
component: "VALARM",
|
||||||
|
property: "TRIGGER",
|
||||||
|
})?;
|
||||||
|
Ok(alarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_action(value: &str) -> Option<AlarmAction> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"DISPLAY" => Some(AlarmAction::Display),
|
||||||
|
"AUDIO" => Some(AlarmAction::Audio),
|
||||||
|
"EMAIL" => Some(AlarmAction::Email),
|
||||||
|
"PROCEDURE" => Some(AlarmAction::Procedure),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TRIGGER` is a duration by default and an absolute time only when the
|
||||||
|
/// producer says `VALUE=DATE-TIME`.
|
||||||
|
///
|
||||||
|
/// `RELATED=END` is honoured here. v1 ignored the parameter and assumed START,
|
||||||
|
/// so an alarm authored elsewhere as "15 minutes before the end" moved to
|
||||||
|
/// 15 minutes before the start the first time Runway touched the event.
|
||||||
|
fn parse_trigger(p: &View<'_>) -> Result<AlarmTrigger, IcalError> {
|
||||||
|
let absolute = p
|
||||||
|
.param("VALUE")
|
||||||
|
.is_some_and(|v| v.eq_ignore_ascii_case("DATE-TIME"))
|
||||||
|
|| p.value.ends_with(['Z', 'z']);
|
||||||
|
if absolute {
|
||||||
|
return Ok(AlarmTrigger::Absolute {
|
||||||
|
at: read_utc("TRIGGER", p.value)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let related = match p.param("RELATED") {
|
||||||
|
Some(v) if v.eq_ignore_ascii_case("END") => TriggerRelation::End,
|
||||||
|
_ => TriggerRelation::Start,
|
||||||
|
};
|
||||||
|
Ok(AlarmTrigger::Relative {
|
||||||
|
offset: read_duration("TRIGGER", p.value)?,
|
||||||
|
related,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- VTIMEZONE --
|
||||||
|
|
||||||
|
fn parse_timezone(raw: &RawComponent<'_>) -> Result<VTimeZone, IcalError> {
|
||||||
|
let mut tz = VTimeZone::new(String::new());
|
||||||
|
for prop in &raw.properties {
|
||||||
|
let p = View::of(prop);
|
||||||
|
match p.name.as_str() {
|
||||||
|
"TZID" => tz.tzid = p.value.to_owned(),
|
||||||
|
"TZURL" => tz.url = Some(p.value.to_owned()),
|
||||||
|
_ => tz.unknown_properties.push(p.into_unknown()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tz.tzid.is_empty() {
|
||||||
|
return Err(IcalError::MissingProperty {
|
||||||
|
component: "VTIMEZONE",
|
||||||
|
property: "TZID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for child in &raw.components {
|
||||||
|
let kind = match child.name.as_str().to_uppercase().as_str() {
|
||||||
|
"STANDARD" => TimeZoneRuleKind::Standard,
|
||||||
|
"DAYLIGHT" => TimeZoneRuleKind::Daylight,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
tz.rules.push(parse_timezone_rule(kind, child)?);
|
||||||
|
}
|
||||||
|
Ok(tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timezone_rule(
|
||||||
|
kind: TimeZoneRuleKind,
|
||||||
|
raw: &RawComponent<'_>,
|
||||||
|
) -> Result<TimeZoneRule, IcalError> {
|
||||||
|
let mut dtstart = None;
|
||||||
|
let mut offset_from = None;
|
||||||
|
let mut offset_to = None;
|
||||||
|
let mut rule = TimeZoneRule {
|
||||||
|
kind,
|
||||||
|
dtstart: DateTime::<Utc>::UNIX_EPOCH.naive_utc(),
|
||||||
|
offset_from: UtcOffset::UTC,
|
||||||
|
offset_to: UtcOffset::UTC,
|
||||||
|
rrule: None,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
names: Vec::new(),
|
||||||
|
unknown_properties: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let component = kind.component_name();
|
||||||
|
for prop in &raw.properties {
|
||||||
|
let p = View::of(prop);
|
||||||
|
match p.name.as_str() {
|
||||||
|
// Always a local time in the offset given by TZOFFSETFROM, and
|
||||||
|
// Exchange writes the year 1601 here, so no range check.
|
||||||
|
"DTSTART" => dtstart = Some(parse_local(p.value)?),
|
||||||
|
"TZOFFSETFROM" => offset_from = Some(parse_offset("TZOFFSETFROM", p.value)?),
|
||||||
|
"TZOFFSETTO" => offset_to = Some(parse_offset("TZOFFSETTO", p.value)?),
|
||||||
|
"RRULE" => rule.rrule = Some(p.value.trim().to_owned()),
|
||||||
|
"RDATE" => {
|
||||||
|
for part in split_list(p.value) {
|
||||||
|
rule.rdate.push(parse_local(part)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"TZNAME" => rule.names.push(p.text()),
|
||||||
|
_ => rule.unknown_properties.push(p.into_unknown()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing = |property| IcalError::MissingProperty {
|
||||||
|
component,
|
||||||
|
property,
|
||||||
|
};
|
||||||
|
rule.dtstart = dtstart.ok_or_else(|| missing("DTSTART"))?;
|
||||||
|
rule.offset_from = offset_from.ok_or_else(|| missing("TZOFFSETFROM"))?;
|
||||||
|
rule.offset_to = offset_to.ok_or_else(|| missing("TZOFFSETTO"))?;
|
||||||
|
Ok(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_local(value: &str) -> Result<NaiveDateTime, IcalError> {
|
||||||
|
NaiveDateTime::parse_from_str(value.trim(), "%Y%m%dT%H%M%S")
|
||||||
|
.map_err(|e| IcalError::invalid("DTSTART", value, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_offset(property: &str, value: &str) -> Result<UtcOffset, IcalError> {
|
||||||
|
UtcOffset::parse(value.trim()).map_err(|e| IcalError::invalid(property, value, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- passthrough ----
|
||||||
|
|
||||||
|
fn carry_component(raw: &RawComponent<'_>) -> UnknownComponent {
|
||||||
|
UnknownComponent {
|
||||||
|
name: raw.name.as_str().to_owned(),
|
||||||
|
properties: raw
|
||||||
|
.properties
|
||||||
|
.iter()
|
||||||
|
.map(|p| View::of(p).into_unknown())
|
||||||
|
.collect(),
|
||||||
|
components: raw.components.iter().map(carry_component).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- view ---
|
||||||
|
|
||||||
|
/// A parsed property, with its name normalised for matching and its parameters
|
||||||
|
/// left as written.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct View<'a> {
|
||||||
|
/// Upper-cased for comparison; property names are case-insensitive.
|
||||||
|
name: String,
|
||||||
|
/// As written, for passthrough.
|
||||||
|
raw_name: &'a str,
|
||||||
|
value: &'a str,
|
||||||
|
params: Vec<(&'a str, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> View<'a> {
|
||||||
|
fn of(prop: &'a RawProperty<'a>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: prop.name.as_str().to_uppercase(),
|
||||||
|
raw_name: prop.name.as_str(),
|
||||||
|
value: prop.val.as_str(),
|
||||||
|
params: prop
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
(
|
||||||
|
p.key.as_str(),
|
||||||
|
p.val
|
||||||
|
.as_ref()
|
||||||
|
.map(|v| v.as_str().to_owned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, name: &str) -> Option<&str> {
|
||||||
|
self.params
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The logical value of a text property.
|
||||||
|
///
|
||||||
|
/// `icalendar`'s parser has already unescaped anything it types as TEXT, so
|
||||||
|
/// unescaping again here would eat a level of backslashes. Everything else
|
||||||
|
/// arrives raw and is unescaped now.
|
||||||
|
fn text(&self) -> String {
|
||||||
|
if value::is_text_value(&self.name, self.param("VALUE")) {
|
||||||
|
self.value.to_owned()
|
||||||
|
} else {
|
||||||
|
value::unescape_text(self.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A comma-separated TEXT list, split before unescaping so an escaped comma
|
||||||
|
/// inside a value is not mistaken for a separator.
|
||||||
|
fn text_list(&self) -> Vec<String> {
|
||||||
|
// CATEGORIES and RESOURCES are TEXT, so the parser has already unescaped
|
||||||
|
// the whole value -- which means an escaped comma inside a category has
|
||||||
|
// become indistinguishable from a separator by the time we see it. That
|
||||||
|
// is a limitation of reading through this parser, documented rather than
|
||||||
|
// papered over; splitting on every comma is what other clients do too.
|
||||||
|
let text = self.text();
|
||||||
|
split_list(&text)
|
||||||
|
.into_iter()
|
||||||
|
.map(|part| part.trim().to_owned())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn datetime(&self) -> Result<crate::model::CalendarDateTime, IcalError> {
|
||||||
|
read_datetime(
|
||||||
|
&self.name,
|
||||||
|
self.value,
|
||||||
|
self.param("VALUE"),
|
||||||
|
self.param("TZID"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn datetime_list(&self) -> Result<Vec<crate::model::CalendarDateTime>, IcalError> {
|
||||||
|
split_list(self.value)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|part| read_datetime(&self.name, part, self.param("VALUE"), self.param("TZID")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters not named here are carried through rather than dropped.
|
||||||
|
fn other_params(&self, known: &[&str]) -> Vec<PropertyParam> {
|
||||||
|
self.params
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !known.iter().any(|n| k.eq_ignore_ascii_case(n)))
|
||||||
|
.map(|(k, v)| PropertyParam::new(*k, v.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn organizer(&self) -> CalendarUser {
|
||||||
|
const KNOWN: &[&str] = &["CN", "DIR", "SENT-BY", "LANGUAGE"];
|
||||||
|
CalendarUser {
|
||||||
|
address: self.value.to_owned(),
|
||||||
|
common_name: self.param("CN").map(str::to_owned),
|
||||||
|
dir_entry: self.param("DIR").map(str::to_owned),
|
||||||
|
sent_by: self.param("SENT-BY").map(str::to_owned),
|
||||||
|
language: self.param("LANGUAGE").map(str::to_owned),
|
||||||
|
unknown_params: self.other_params(KNOWN),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attendee(&self) -> Attendee {
|
||||||
|
const KNOWN: &[&str] = &[
|
||||||
|
"CN",
|
||||||
|
"ROLE",
|
||||||
|
"PARTSTAT",
|
||||||
|
"CUTYPE",
|
||||||
|
"RSVP",
|
||||||
|
"MEMBER",
|
||||||
|
"DELEGATED-TO",
|
||||||
|
"DELEGATED-FROM",
|
||||||
|
"SENT-BY",
|
||||||
|
"LANGUAGE",
|
||||||
|
];
|
||||||
|
let list = |name: &str| {
|
||||||
|
self.param(name)
|
||||||
|
.map(|v| split_list(v).into_iter().map(str::to_owned).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
Attendee {
|
||||||
|
address: self.value.to_owned(),
|
||||||
|
common_name: self.param("CN").map(str::to_owned),
|
||||||
|
role: self.param("ROLE").and_then(parse_role),
|
||||||
|
participation_status: self.param("PARTSTAT").and_then(parse_partstat),
|
||||||
|
user_type: self.param("CUTYPE").and_then(parse_cutype),
|
||||||
|
rsvp: self.param("RSVP").map(|v| v.eq_ignore_ascii_case("TRUE")),
|
||||||
|
member: list("MEMBER"),
|
||||||
|
delegated_to: list("DELEGATED-TO"),
|
||||||
|
delegated_from: list("DELEGATED-FROM"),
|
||||||
|
sent_by: self.param("SENT-BY").map(str::to_owned),
|
||||||
|
language: self.param("LANGUAGE").map(str::to_owned),
|
||||||
|
unknown_params: self.other_params(KNOWN),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_unknown(self) -> UnknownProperty {
|
||||||
|
UnknownProperty {
|
||||||
|
name: self.raw_name.to_owned(),
|
||||||
|
params: self
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| PropertyParam::new(*k, v.clone()))
|
||||||
|
.collect(),
|
||||||
|
value: self.value.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_role(value: &str) -> Option<Role> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"CHAIR" => Some(Role::Chair),
|
||||||
|
"REQ-PARTICIPANT" => Some(Role::ReqParticipant),
|
||||||
|
"OPT-PARTICIPANT" => Some(Role::OptParticipant),
|
||||||
|
"NON-PARTICIPANT" => Some(Role::NonParticipant),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_partstat(value: &str) -> Option<ParticipationStatus> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"NEEDS-ACTION" => Some(ParticipationStatus::NeedsAction),
|
||||||
|
"ACCEPTED" => Some(ParticipationStatus::Accepted),
|
||||||
|
"DECLINED" => Some(ParticipationStatus::Declined),
|
||||||
|
"TENTATIVE" => Some(ParticipationStatus::Tentative),
|
||||||
|
"DELEGATED" => Some(ParticipationStatus::Delegated),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cutype(value: &str) -> Option<CalendarUserType> {
|
||||||
|
match value.trim().to_uppercase().as_str() {
|
||||||
|
"INDIVIDUAL" => Some(CalendarUserType::Individual),
|
||||||
|
"GROUP" => Some(CalendarUserType::Group),
|
||||||
|
"RESOURCE" => Some(CalendarUserType::Resource),
|
||||||
|
"ROOM" => Some(CalendarUserType::Room),
|
||||||
|
_ => Some(CalendarUserType::Unknown),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
//! Reading and writing individual RFC 5545 property values.
|
||||||
|
//!
|
||||||
|
//! Text escaping and line folding live here rather than being borrowed from
|
||||||
|
//! `icalendar`'s writer, for one concrete reason: that writer escapes a whole
|
||||||
|
//! property value as text, which turns the separators in a multi-value property
|
||||||
|
//! into literal characters. `CATEGORIES:Work,Personal` would go out as
|
||||||
|
//! `CATEGORIES:Work\,Personal`, and every other client would read one category
|
||||||
|
//! named "Work,Personal" instead of two. A round-trip through our own parser
|
||||||
|
//! would not notice, because escape and unescape are symmetric — the damage is
|
||||||
|
//! only visible to somebody else's calendar app, which is exactly the kind of
|
||||||
|
//! bug this rewrite exists to stop shipping.
|
||||||
|
//!
|
||||||
|
//! So values are escaped per component, then joined. Reading still goes through
|
||||||
|
//! `icalendar`'s parser, which owns the grammar and the unfolding.
|
||||||
|
|
||||||
|
use crate::ical::error::IcalError;
|
||||||
|
use crate::model::{CalendarDateTime, IcalDuration, TzId, UtcOffset};
|
||||||
|
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeDelta, Utc};
|
||||||
|
|
||||||
|
const DATE: &str = "%Y%m%d";
|
||||||
|
const DATE_TIME: &str = "%Y%m%dT%H%M%S";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- reading ---
|
||||||
|
|
||||||
|
/// Reads a `DATE` or `DATE-TIME` value together with the parameters that give
|
||||||
|
/// it meaning.
|
||||||
|
///
|
||||||
|
/// The four RFC 5545 forms are distinguished here and nowhere else, which is
|
||||||
|
/// what keeps a floating time from being silently promoted to UTC — the
|
||||||
|
/// previous iteration's `parse_datetime` did exactly that, commenting "if no
|
||||||
|
/// TZID parameter is provided, treat as UTC", and recurring events drifted an
|
||||||
|
/// hour whenever the reader was not in UTC.
|
||||||
|
pub fn read_datetime(
|
||||||
|
property: &str,
|
||||||
|
value: &str,
|
||||||
|
value_type: Option<&str>,
|
||||||
|
tzid: Option<&str>,
|
||||||
|
) -> Result<CalendarDateTime, IcalError> {
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
if value_type.is_some_and(|v| v.eq_ignore_ascii_case("DATE")) || is_bare_date(value) {
|
||||||
|
let date = NaiveDate::parse_from_str(value, DATE)
|
||||||
|
.map_err(|e| IcalError::invalid(property, value, e.to_string()))?;
|
||||||
|
return Ok(CalendarDateTime::Date { date });
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stripped) = value.strip_suffix(['Z', 'z']) {
|
||||||
|
let naive = NaiveDateTime::parse_from_str(stripped, DATE_TIME)
|
||||||
|
.map_err(|e| IcalError::invalid(property, value, e.to_string()))?;
|
||||||
|
return Ok(CalendarDateTime::Utc {
|
||||||
|
utc: naive.and_utc(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let local = NaiveDateTime::parse_from_str(value, DATE_TIME)
|
||||||
|
.map_err(|e| IcalError::invalid(property, value, e.to_string()))?;
|
||||||
|
|
||||||
|
match tzid {
|
||||||
|
// The identifier is kept exactly as written. Normalising a Windows zone
|
||||||
|
// name such as "Pacific Standard Time" to IANA here would make the
|
||||||
|
// document unrepresentable, and the VTIMEZONE that explains it is
|
||||||
|
// keyed on the original string.
|
||||||
|
Some(id) => Ok(CalendarDateTime::Zoned {
|
||||||
|
local,
|
||||||
|
tzid: TzId::new(id).map_err(|e| IcalError::invalid(property, value, e.to_string()))?,
|
||||||
|
}),
|
||||||
|
None => Ok(CalendarDateTime::Floating { local }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_bare_date(value: &str) -> bool {
|
||||||
|
value.len() == 8 && value.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a value that RFC 5545 defines as always-UTC: `DTSTAMP`, `CREATED`,
|
||||||
|
/// `LAST-MODIFIED`, `ACKNOWLEDGED`.
|
||||||
|
///
|
||||||
|
/// Lenient about a missing `Z`, because real data lacks it — Runway v1 itself
|
||||||
|
/// wrote `CREATED:20251125T211136`, and those events are still on the server.
|
||||||
|
/// The value is interpreted as UTC and written back correctly, which repairs
|
||||||
|
/// the record rather than propagating the defect.
|
||||||
|
pub fn read_utc(property: &str, value: &str) -> Result<DateTime<Utc>, IcalError> {
|
||||||
|
let value = value.trim();
|
||||||
|
let stripped = value.strip_suffix(['Z', 'z']).unwrap_or(value);
|
||||||
|
if let Ok(naive) = NaiveDateTime::parse_from_str(stripped, DATE_TIME) {
|
||||||
|
return Ok(naive.and_utc());
|
||||||
|
}
|
||||||
|
NaiveDate::parse_from_str(stripped, DATE)
|
||||||
|
.map(|d| d.and_time(chrono::NaiveTime::MIN).and_utc())
|
||||||
|
.map_err(|e| IcalError::invalid(property, value, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `DURATION` value — RFC 5545 §3.3.6, `[+-]P[nW][nD][T[nH][nM][nS]]`.
|
||||||
|
pub fn read_duration(property: &str, value: &str) -> Result<IcalDuration, IcalError> {
|
||||||
|
let raw = value.trim();
|
||||||
|
let err = |reason: &str| IcalError::invalid(property, raw, reason.to_owned());
|
||||||
|
|
||||||
|
let (sign, rest) = match raw.as_bytes().first() {
|
||||||
|
Some(b'-') => (-1i64, &raw[1..]),
|
||||||
|
Some(b'+') => (1, &raw[1..]),
|
||||||
|
_ => (1, raw),
|
||||||
|
};
|
||||||
|
let rest = rest
|
||||||
|
.strip_prefix(['P', 'p'])
|
||||||
|
.ok_or_else(|| err("missing P"))?;
|
||||||
|
|
||||||
|
let mut seconds: i64 = 0;
|
||||||
|
let mut digits = String::new();
|
||||||
|
let mut in_time = false;
|
||||||
|
let mut saw_unit = false;
|
||||||
|
|
||||||
|
for ch in rest.chars() {
|
||||||
|
match ch {
|
||||||
|
'T' | 't' => in_time = true,
|
||||||
|
'0'..='9' => digits.push(ch),
|
||||||
|
unit => {
|
||||||
|
let n: i64 = digits
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| err("a unit with no number before it"))?;
|
||||||
|
digits.clear();
|
||||||
|
saw_unit = true;
|
||||||
|
seconds += match (unit, in_time) {
|
||||||
|
('W' | 'w', _) => n * 7 * 86_400,
|
||||||
|
('D' | 'd', _) => n * 86_400,
|
||||||
|
('H' | 'h', true) => n * 3_600,
|
||||||
|
('M' | 'm', true) => n * 60,
|
||||||
|
('S' | 's', true) => n,
|
||||||
|
_ => return Err(err("unit is not valid in this position")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !digits.is_empty() {
|
||||||
|
return Err(err("trailing number with no unit"));
|
||||||
|
}
|
||||||
|
if !saw_unit {
|
||||||
|
return Err(err("duration has no components"));
|
||||||
|
}
|
||||||
|
TimeDelta::try_seconds(sign * seconds)
|
||||||
|
.map(IcalDuration::from)
|
||||||
|
.ok_or_else(|| err("duration out of range"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a multi-value property on its unescaped commas.
|
||||||
|
///
|
||||||
|
/// A comma written `\,` is part of a value; a bare comma separates values.
|
||||||
|
/// `icalendar` has already unescaped whole-value TEXT properties by the time we
|
||||||
|
/// see them, so this is applied to the value types it leaves alone —
|
||||||
|
/// date-times, and the text lists we escape ourselves.
|
||||||
|
pub fn split_list(value: &str) -> Vec<&str> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let (mut start, mut escaped) = (0, false);
|
||||||
|
for (i, ch) in value.char_indices() {
|
||||||
|
match ch {
|
||||||
|
_ if escaped => escaped = false,
|
||||||
|
'\\' => escaped = true,
|
||||||
|
',' => {
|
||||||
|
parts.push(&value[start..i]);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(&value[start..]);
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property names RFC 5545 gives a `TEXT` value, which is what decides
|
||||||
|
/// whether a value arrives from the parser already unescaped.
|
||||||
|
///
|
||||||
|
/// This mirrors `icalendar`'s own table rather than guessing at it: the parser
|
||||||
|
/// unescapes exactly these, so the writer must re-escape exactly these. Getting
|
||||||
|
/// the two halves out of step would either double the backslashes on every
|
||||||
|
/// write or strip them.
|
||||||
|
const TEXT_PROPERTIES: &[&str] = &[
|
||||||
|
"ACTION",
|
||||||
|
"CALSCALE",
|
||||||
|
"CATEGORIES",
|
||||||
|
"CLASS",
|
||||||
|
"COMMENT",
|
||||||
|
"CONTACT",
|
||||||
|
"DESCRIPTION",
|
||||||
|
"LOCATION",
|
||||||
|
"METHOD",
|
||||||
|
"PRODID",
|
||||||
|
"RELATED-TO",
|
||||||
|
"REQUEST-STATUS",
|
||||||
|
"RESOURCES",
|
||||||
|
"STATUS",
|
||||||
|
"SUMMARY",
|
||||||
|
"TRANSP",
|
||||||
|
"TZID",
|
||||||
|
"TZNAME",
|
||||||
|
"UID",
|
||||||
|
"VERSION",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether a property's value is TEXT, and therefore escaped on the wire.
|
||||||
|
///
|
||||||
|
/// An explicit `VALUE=` parameter wins; otherwise the name decides. Every `X-`
|
||||||
|
/// property counts as text, which is easy to overlook — an unrecognised
|
||||||
|
/// `X-MICROSOFT-LOCATIONS` carrying a JSON blob full of commas is escaped data,
|
||||||
|
/// not raw data, and writing it back unescaped would corrupt it.
|
||||||
|
pub fn is_text_value(name: &str, value_param: Option<&str>) -> bool {
|
||||||
|
if let Some(declared) = value_param {
|
||||||
|
return declared.eq_ignore_ascii_case("TEXT");
|
||||||
|
}
|
||||||
|
let upper = name.to_ascii_uppercase();
|
||||||
|
upper.starts_with("X-") || TEXT_PROPERTIES.contains(&upper.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverses [`escape_text`].
|
||||||
|
pub fn unescape_text(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len());
|
||||||
|
let mut chars = value.chars();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch != '\\' {
|
||||||
|
out.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match chars.next() {
|
||||||
|
Some('n' | 'N') => out.push('\n'),
|
||||||
|
Some('\\') => out.push('\\'),
|
||||||
|
Some(',') => out.push(','),
|
||||||
|
Some(';') => out.push(';'),
|
||||||
|
// An unknown escape is passed through with its backslash, matching
|
||||||
|
// what `icalendar` does, so the two readers never disagree.
|
||||||
|
Some(other) => {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(other);
|
||||||
|
}
|
||||||
|
None => out.push('\\'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- writing ---
|
||||||
|
|
||||||
|
/// Escapes one TEXT value — RFC 5545 §3.3.11.
|
||||||
|
///
|
||||||
|
/// Applied per value, never to a joined list, so separators survive.
|
||||||
|
pub fn escape_text(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 8);
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
',' => out.push_str("\\,"),
|
||||||
|
';' => out.push_str("\\;"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
// A bare CR has no representation and no meaning inside a value.
|
||||||
|
'\r' => {}
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escapes each value and joins them with the separator commas left bare.
|
||||||
|
pub fn write_list<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.map(escape_text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a date or date-time, returning the value and the `VALUE=`/`TZID=`
|
||||||
|
/// parameters it needs.
|
||||||
|
pub fn write_datetime(dt: &CalendarDateTime) -> (String, Vec<(String, String)>) {
|
||||||
|
match dt {
|
||||||
|
CalendarDateTime::Date { date } => (
|
||||||
|
date.format(DATE).to_string(),
|
||||||
|
vec![("VALUE".to_owned(), "DATE".to_owned())],
|
||||||
|
),
|
||||||
|
CalendarDateTime::Floating { local } => (local.format(DATE_TIME).to_string(), Vec::new()),
|
||||||
|
CalendarDateTime::Utc { utc } => (format!("{}Z", utc.format(DATE_TIME)), Vec::new()),
|
||||||
|
CalendarDateTime::Zoned { local, tzid } => (
|
||||||
|
local.format(DATE_TIME).to_string(),
|
||||||
|
vec![("TZID".to_owned(), tzid.as_str().to_owned())],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders an always-UTC value.
|
||||||
|
pub fn write_utc(at: DateTime<Utc>) -> String {
|
||||||
|
format!("{}Z", at.format(DATE_TIME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a duration in the `PnDTnHnMnS` form.
|
||||||
|
///
|
||||||
|
/// Weeks are only used for an exact multiple of seven days, which is what other
|
||||||
|
/// clients emit and what keeps `-P1W` from becoming `-P7D` on a round-trip.
|
||||||
|
pub fn write_duration(duration: IcalDuration) -> String {
|
||||||
|
let total = duration.as_time_delta().num_seconds();
|
||||||
|
if total == 0 {
|
||||||
|
return "PT0S".to_owned();
|
||||||
|
}
|
||||||
|
let sign = if total < 0 { "-" } else { "" };
|
||||||
|
let mut left = total.unsigned_abs();
|
||||||
|
|
||||||
|
const WEEK: u64 = 7 * 86_400;
|
||||||
|
if left.is_multiple_of(WEEK) {
|
||||||
|
return format!("{sign}P{}W", left / WEEK);
|
||||||
|
}
|
||||||
|
|
||||||
|
let days = left / 86_400;
|
||||||
|
left %= 86_400;
|
||||||
|
let (h, m, s) = (left / 3_600, (left % 3_600) / 60, left % 60);
|
||||||
|
|
||||||
|
let mut out = format!("{sign}P");
|
||||||
|
if days > 0 {
|
||||||
|
out.push_str(&format!("{days}D"));
|
||||||
|
}
|
||||||
|
if h > 0 || m > 0 || s > 0 {
|
||||||
|
out.push('T');
|
||||||
|
if h > 0 {
|
||||||
|
out.push_str(&format!("{h}H"));
|
||||||
|
}
|
||||||
|
if m > 0 {
|
||||||
|
out.push_str(&format!("{m}M"));
|
||||||
|
}
|
||||||
|
if s > 0 {
|
||||||
|
out.push_str(&format!("{s}S"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a `TZOFFSETFROM` / `TZOFFSETTO` value.
|
||||||
|
pub fn write_offset(offset: UtcOffset) -> String {
|
||||||
|
offset.to_ical()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds a content line to 75 octets — RFC 5545 §3.1.
|
||||||
|
///
|
||||||
|
/// Splits on octet count, never inside a UTF-8 character, and starts each
|
||||||
|
/// continuation with a single space. v1 emitted no folding at all, and the
|
||||||
|
/// 248-octet `DESCRIPTION` lines it wrote are still sitting on the server.
|
||||||
|
pub fn fold(line: &str) -> String {
|
||||||
|
const LIMIT: usize = 75;
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
if bytes.len() <= LIMIT {
|
||||||
|
return line.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(bytes.len() + bytes.len() / LIMIT * 3);
|
||||||
|
let mut start = 0;
|
||||||
|
let mut first = true;
|
||||||
|
while start < bytes.len() {
|
||||||
|
// A continuation spends one of its 75 octets on the leading space.
|
||||||
|
let budget = if first { LIMIT } else { LIMIT - 1 };
|
||||||
|
let mut end = (start + budget).min(bytes.len());
|
||||||
|
while end > start && !line.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
if !first {
|
||||||
|
out.push_str("\r\n ");
|
||||||
|
}
|
||||||
|
out.push_str(&line[start..end]);
|
||||||
|
start = end;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! Writing the domain model back out as a `VCALENDAR`.
|
||||||
|
//!
|
||||||
|
//! Property order is fixed rather than incidental, so the same event always
|
||||||
|
//! produces the same bytes. That matters for CalDAV: an `ETag` changes when the
|
||||||
|
//! resource does, and a writer that shuffled its output would make every read
|
||||||
|
//! look like a remote edit.
|
||||||
|
|
||||||
|
use super::value::{
|
||||||
|
escape_text, fold, is_text_value, write_datetime, write_duration, write_list, write_offset,
|
||||||
|
write_utc,
|
||||||
|
};
|
||||||
|
use crate::model::{
|
||||||
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventEnd, EventStatus,
|
||||||
|
TimeZoneRule, Transparency, TriggerRelation, UnknownComponent, UnknownProperty, VAlarm,
|
||||||
|
VCalendar, VEvent, VTimeZone,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders a calendar as an iCalendar document, CRLF-terminated and folded to
|
||||||
|
/// 75 octets.
|
||||||
|
pub fn write(calendar: &VCalendar) -> String {
|
||||||
|
let mut out = String::with_capacity(1024);
|
||||||
|
begin(&mut out, "VCALENDAR");
|
||||||
|
// PRODID and VERSION first: some consumers, Outlook among them, are unhappy
|
||||||
|
// when VERSION arrives late.
|
||||||
|
line(&mut out, "PRODID", &[], &escape_text(&calendar.prodid));
|
||||||
|
line(&mut out, "VERSION", &[], &escape_text(&calendar.version));
|
||||||
|
if let Some(calscale) = &calendar.calscale {
|
||||||
|
line(&mut out, "CALSCALE", &[], &escape_text(calscale));
|
||||||
|
}
|
||||||
|
if let Some(method) = &calendar.method {
|
||||||
|
line(&mut out, "METHOD", &[], &escape_text(method));
|
||||||
|
}
|
||||||
|
unknown_properties(&mut out, &calendar.unknown_properties);
|
||||||
|
|
||||||
|
// Zone definitions before the events that reference them.
|
||||||
|
for tz in &calendar.timezones {
|
||||||
|
write_timezone(&mut out, tz);
|
||||||
|
}
|
||||||
|
for event in &calendar.events {
|
||||||
|
write_event(&mut out, event);
|
||||||
|
}
|
||||||
|
for component in &calendar.unknown_components {
|
||||||
|
write_unknown_component(&mut out, component);
|
||||||
|
}
|
||||||
|
end(&mut out, "VCALENDAR");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ VEVENT --
|
||||||
|
|
||||||
|
fn write_event(out: &mut String, ev: &VEvent) {
|
||||||
|
begin(out, "VEVENT");
|
||||||
|
line(out, "UID", &[], &escape_text(&ev.uid));
|
||||||
|
line(out, "DTSTAMP", &[], &write_utc(ev.dtstamp));
|
||||||
|
datetime(out, "DTSTART", &ev.dtstart);
|
||||||
|
|
||||||
|
match &ev.end {
|
||||||
|
Some(EventEnd::DateTime { dtend }) => datetime(out, "DTEND", dtend),
|
||||||
|
Some(EventEnd::Duration { duration }) => {
|
||||||
|
line(out, "DURATION", &[], &write_duration(*duration));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
text(out, "SUMMARY", ev.summary.as_deref());
|
||||||
|
text(out, "DESCRIPTION", ev.description.as_deref());
|
||||||
|
text(out, "LOCATION", ev.location.as_deref());
|
||||||
|
if let Some(url) = &ev.url {
|
||||||
|
line(out, "URL", &[], url);
|
||||||
|
}
|
||||||
|
if let Some(geo) = &ev.geo {
|
||||||
|
line(
|
||||||
|
out,
|
||||||
|
"GEO",
|
||||||
|
&[],
|
||||||
|
&format!("{};{}", geo.latitude, geo.longitude),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(status) = ev.status {
|
||||||
|
line(out, "STATUS", &[], status_value(status));
|
||||||
|
}
|
||||||
|
if let Some(class) = ev.class {
|
||||||
|
line(out, "CLASS", &[], class_value(class));
|
||||||
|
}
|
||||||
|
if let Some(transp) = ev.transparency {
|
||||||
|
line(out, "TRANSP", &[], transparency_value(transp));
|
||||||
|
}
|
||||||
|
if let Some(priority) = ev.priority {
|
||||||
|
line(out, "PRIORITY", &[], &priority.get().to_string());
|
||||||
|
}
|
||||||
|
if let Some(organizer) = &ev.organizer {
|
||||||
|
write_organizer(out, organizer);
|
||||||
|
}
|
||||||
|
for attendee in &ev.attendees {
|
||||||
|
write_attendee(out, attendee);
|
||||||
|
}
|
||||||
|
text(out, "CONTACT", ev.contact.as_deref());
|
||||||
|
if !ev.categories.is_empty() {
|
||||||
|
line(
|
||||||
|
out,
|
||||||
|
"CATEGORIES",
|
||||||
|
&[],
|
||||||
|
&write_list(ev.categories.iter().map(String::as_str)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !ev.resources.is_empty() {
|
||||||
|
line(
|
||||||
|
out,
|
||||||
|
"RESOURCES",
|
||||||
|
&[],
|
||||||
|
&write_list(ev.resources.iter().map(String::as_str)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
text(out, "COMMENT", ev.comment.as_deref());
|
||||||
|
text(out, "RELATED-TO", ev.related_to.as_deref());
|
||||||
|
|
||||||
|
if let Some(rrule) = &ev.rrule {
|
||||||
|
line(out, "RRULE", &[], rrule);
|
||||||
|
}
|
||||||
|
datetime_list(out, "RDATE", &ev.rdate);
|
||||||
|
datetime_list(out, "EXDATE", &ev.exdate);
|
||||||
|
if let Some(recurrence_id) = &ev.recurrence_id {
|
||||||
|
datetime(out, "RECURRENCE-ID", recurrence_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always written, even at zero. RFC 5545 defaults SEQUENCE to 0, so
|
||||||
|
// omitting it means the same thing -- but a property that goes in and does
|
||||||
|
// not come out is the shape of bug this layer exists to prevent, and the
|
||||||
|
// round-trip test is only able to police that if there are no exceptions.
|
||||||
|
line(out, "SEQUENCE", &[], &ev.sequence.to_string());
|
||||||
|
if let Some(created) = ev.created {
|
||||||
|
line(out, "CREATED", &[], &write_utc(created));
|
||||||
|
}
|
||||||
|
if let Some(modified) = ev.last_modified {
|
||||||
|
line(out, "LAST-MODIFIED", &[], &write_utc(modified));
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown_properties(out, &ev.unknown_properties);
|
||||||
|
for alarm in &ev.alarms {
|
||||||
|
write_alarm(out, alarm);
|
||||||
|
}
|
||||||
|
end(out, "VEVENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_organizer(out: &mut String, user: &CalendarUser) {
|
||||||
|
let mut params: Vec<(String, String)> = Vec::new();
|
||||||
|
push_param(&mut params, "CN", user.common_name.as_deref());
|
||||||
|
push_param(&mut params, "DIR", user.dir_entry.as_deref());
|
||||||
|
push_param(&mut params, "SENT-BY", user.sent_by.as_deref());
|
||||||
|
push_param(&mut params, "LANGUAGE", user.language.as_deref());
|
||||||
|
carry_params(&mut params, &user.unknown_params);
|
||||||
|
line(out, "ORGANIZER", &borrow(¶ms), &user.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_attendee(out: &mut String, attendee: &Attendee) {
|
||||||
|
let mut params: Vec<(String, String)> = Vec::new();
|
||||||
|
push_param(&mut params, "CN", attendee.common_name.as_deref());
|
||||||
|
if let Some(role) = attendee.role {
|
||||||
|
params.push(("ROLE".to_owned(), role_value(role).to_owned()));
|
||||||
|
}
|
||||||
|
if let Some(status) = attendee.participation_status {
|
||||||
|
params.push(("PARTSTAT".to_owned(), partstat_value(status).to_owned()));
|
||||||
|
}
|
||||||
|
if let Some(cutype) = attendee.user_type {
|
||||||
|
params.push(("CUTYPE".to_owned(), cutype_value(cutype).to_owned()));
|
||||||
|
}
|
||||||
|
if let Some(rsvp) = attendee.rsvp {
|
||||||
|
params.push((
|
||||||
|
"RSVP".to_owned(),
|
||||||
|
if rsvp { "TRUE" } else { "FALSE" }.to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (name, values) in [
|
||||||
|
("MEMBER", &attendee.member),
|
||||||
|
("DELEGATED-TO", &attendee.delegated_to),
|
||||||
|
("DELEGATED-FROM", &attendee.delegated_from),
|
||||||
|
] {
|
||||||
|
if !values.is_empty() {
|
||||||
|
params.push((name.to_owned(), values.join(",")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_param(&mut params, "SENT-BY", attendee.sent_by.as_deref());
|
||||||
|
push_param(&mut params, "LANGUAGE", attendee.language.as_deref());
|
||||||
|
carry_params(&mut params, &attendee.unknown_params);
|
||||||
|
line(out, "ATTENDEE", &borrow(¶ms), &attendee.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ VALARM --
|
||||||
|
|
||||||
|
fn write_alarm(out: &mut String, alarm: &VAlarm) {
|
||||||
|
begin(out, "VALARM");
|
||||||
|
line(out, "ACTION", &[], action_value(alarm.action));
|
||||||
|
match &alarm.trigger {
|
||||||
|
AlarmTrigger::Relative { offset, related } => {
|
||||||
|
// RELATED=START is the default, so it is only worth stating when it
|
||||||
|
// is not. Writing END when the source said END is the point: v1
|
||||||
|
// dropped the parameter and silently moved those alarms.
|
||||||
|
let params: Vec<(&str, &str)> = match related {
|
||||||
|
TriggerRelation::End => vec![("RELATED", "END")],
|
||||||
|
TriggerRelation::Start => Vec::new(),
|
||||||
|
};
|
||||||
|
line(out, "TRIGGER", ¶ms, &write_duration(*offset));
|
||||||
|
}
|
||||||
|
AlarmTrigger::Absolute { at } => {
|
||||||
|
line(out, "TRIGGER", &[("VALUE", "DATE-TIME")], &write_utc(*at));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(duration) = alarm.duration {
|
||||||
|
line(out, "DURATION", &[], &write_duration(duration));
|
||||||
|
}
|
||||||
|
if let Some(repeat) = alarm.repeat {
|
||||||
|
line(out, "REPEAT", &[], &repeat.to_string());
|
||||||
|
}
|
||||||
|
text(out, "DESCRIPTION", alarm.description.as_deref());
|
||||||
|
text(out, "SUMMARY", alarm.summary.as_deref());
|
||||||
|
for attendee in &alarm.attendees {
|
||||||
|
write_attendee(out, attendee);
|
||||||
|
}
|
||||||
|
unknown_properties(out, &alarm.unknown_properties);
|
||||||
|
end(out, "VALARM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- VTIMEZONE --
|
||||||
|
|
||||||
|
fn write_timezone(out: &mut String, tz: &VTimeZone) {
|
||||||
|
begin(out, "VTIMEZONE");
|
||||||
|
line(out, "TZID", &[], &escape_text(&tz.tzid));
|
||||||
|
if let Some(url) = &tz.url {
|
||||||
|
line(out, "TZURL", &[], url);
|
||||||
|
}
|
||||||
|
unknown_properties(out, &tz.unknown_properties);
|
||||||
|
for rule in &tz.rules {
|
||||||
|
write_timezone_rule(out, rule);
|
||||||
|
}
|
||||||
|
end(out, "VTIMEZONE");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_timezone_rule(out: &mut String, rule: &TimeZoneRule) {
|
||||||
|
let name = rule.kind.component_name();
|
||||||
|
begin(out, name);
|
||||||
|
line(
|
||||||
|
out,
|
||||||
|
"DTSTART",
|
||||||
|
&[],
|
||||||
|
&rule.dtstart.format("%Y%m%dT%H%M%S").to_string(),
|
||||||
|
);
|
||||||
|
line(out, "TZOFFSETFROM", &[], &write_offset(rule.offset_from));
|
||||||
|
line(out, "TZOFFSETTO", &[], &write_offset(rule.offset_to));
|
||||||
|
if let Some(rrule) = &rule.rrule {
|
||||||
|
line(out, "RRULE", &[], rrule);
|
||||||
|
}
|
||||||
|
if !rule.rdate.is_empty() {
|
||||||
|
let values: Vec<String> = rule
|
||||||
|
.rdate
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.format("%Y%m%dT%H%M%S").to_string())
|
||||||
|
.collect();
|
||||||
|
line(out, "RDATE", &[], &values.join(","));
|
||||||
|
}
|
||||||
|
for tzname in &rule.names {
|
||||||
|
line(out, "TZNAME", &[], &escape_text(tzname));
|
||||||
|
}
|
||||||
|
unknown_properties(out, &rule.unknown_properties);
|
||||||
|
end(out, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- passthrough ----
|
||||||
|
|
||||||
|
fn unknown_properties(out: &mut String, properties: &[UnknownProperty]) {
|
||||||
|
for property in properties {
|
||||||
|
let params: Vec<(&str, &str)> = property
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.name.as_str(), p.value.as_str()))
|
||||||
|
.collect();
|
||||||
|
// Re-escape exactly what the parser unescaped, and nothing else.
|
||||||
|
let value = if is_text_value(&property.name, property.param("VALUE")) {
|
||||||
|
escape_text(&property.value)
|
||||||
|
} else {
|
||||||
|
property.value.clone()
|
||||||
|
};
|
||||||
|
line(out, &property.name, ¶ms, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_unknown_component(out: &mut String, component: &UnknownComponent) {
|
||||||
|
begin(out, &component.name);
|
||||||
|
unknown_properties(out, &component.properties);
|
||||||
|
for child in &component.components {
|
||||||
|
write_unknown_component(out, child);
|
||||||
|
}
|
||||||
|
end(out, &component.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- mechanics ---
|
||||||
|
|
||||||
|
fn begin(out: &mut String, name: &str) {
|
||||||
|
out.push_str("BEGIN:");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(out: &mut String, name: &str) {
|
||||||
|
out.push_str("END:");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits one content line, folded.
|
||||||
|
fn line(out: &mut String, name: &str, params: &[(&str, &str)], value: &str) {
|
||||||
|
let mut content = String::with_capacity(name.len() + value.len() + 16);
|
||||||
|
content.push_str(name);
|
||||||
|
for (key, val) in params {
|
||||||
|
content.push(';');
|
||||||
|
content.push_str(key);
|
||||||
|
content.push('=');
|
||||||
|
content.push_str("e_param(val));
|
||||||
|
}
|
||||||
|
content.push(':');
|
||||||
|
content.push_str(value);
|
||||||
|
out.push_str(&fold(&content));
|
||||||
|
out.push_str("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quotes a parameter value when it contains a character that would otherwise
|
||||||
|
/// end it — a `CN` of `"Johnstone, Connor"` is real data, and unquoted it would
|
||||||
|
/// read as two parameters.
|
||||||
|
fn quote_param(value: &str) -> String {
|
||||||
|
if value.starts_with('"') && value.ends_with('"') && value.len() > 1 {
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
if value.contains([';', ':', ',']) {
|
||||||
|
format!("\"{}\"", value.replace('"', ""))
|
||||||
|
} else {
|
||||||
|
value.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(out: &mut String, name: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
line(out, name, &[], &escape_text(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn datetime(out: &mut String, name: &str, value: &crate::model::CalendarDateTime) {
|
||||||
|
let (rendered, params) = write_datetime(value);
|
||||||
|
let params: Vec<(&str, &str)> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect();
|
||||||
|
line(out, name, ¶ms, &rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits `RDATE`/`EXDATE`, grouping values that share a form onto one line, the
|
||||||
|
/// way producers do.
|
||||||
|
fn datetime_list(out: &mut String, name: &str, values: &[crate::model::CalendarDateTime]) {
|
||||||
|
if values.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/// Values sharing one set of parameters, so they can share one line.
|
||||||
|
struct Group {
|
||||||
|
params: Vec<(String, String)>,
|
||||||
|
values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut groups: Vec<Group> = Vec::new();
|
||||||
|
for value in values {
|
||||||
|
let (rendered, params) = write_datetime(value);
|
||||||
|
match groups.iter_mut().find(|g| g.params == params) {
|
||||||
|
Some(group) => group.values.push(rendered),
|
||||||
|
None => groups.push(Group {
|
||||||
|
params,
|
||||||
|
values: vec![rendered],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for group in groups {
|
||||||
|
line(out, name, &borrow(&group.params), &group.values.join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends the parameters the model does not interpret, in their original
|
||||||
|
/// order, after the ones it does.
|
||||||
|
fn carry_params(params: &mut Vec<(String, String)>, extra: &[crate::model::PropertyParam]) {
|
||||||
|
params.extend(
|
||||||
|
extra
|
||||||
|
.iter()
|
||||||
|
.map(|param| (param.name.clone(), param.value.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_param(params: &mut Vec<(String, String)>, name: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
params.push((name.to_owned(), value.to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn borrow(params: &[(String, String)]) -> Vec<(&str, &str)> {
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- enum values --
|
||||||
|
|
||||||
|
fn status_value(status: EventStatus) -> &'static str {
|
||||||
|
match status {
|
||||||
|
EventStatus::Tentative => "TENTATIVE",
|
||||||
|
EventStatus::Confirmed => "CONFIRMED",
|
||||||
|
EventStatus::Cancelled => "CANCELLED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_value(class: EventClass) -> &'static str {
|
||||||
|
match class {
|
||||||
|
EventClass::Public => "PUBLIC",
|
||||||
|
EventClass::Private => "PRIVATE",
|
||||||
|
EventClass::Confidential => "CONFIDENTIAL",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transparency_value(transparency: Transparency) -> &'static str {
|
||||||
|
match transparency {
|
||||||
|
Transparency::Opaque => "OPAQUE",
|
||||||
|
Transparency::Transparent => "TRANSPARENT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_value(action: AlarmAction) -> &'static str {
|
||||||
|
match action {
|
||||||
|
AlarmAction::Display => "DISPLAY",
|
||||||
|
AlarmAction::Audio => "AUDIO",
|
||||||
|
AlarmAction::Email => "EMAIL",
|
||||||
|
AlarmAction::Procedure => "PROCEDURE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_value(role: crate::model::Role) -> &'static str {
|
||||||
|
use crate::model::Role;
|
||||||
|
match role {
|
||||||
|
Role::Chair => "CHAIR",
|
||||||
|
Role::ReqParticipant => "REQ-PARTICIPANT",
|
||||||
|
Role::OptParticipant => "OPT-PARTICIPANT",
|
||||||
|
Role::NonParticipant => "NON-PARTICIPANT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partstat_value(status: crate::model::ParticipationStatus) -> &'static str {
|
||||||
|
use crate::model::ParticipationStatus as P;
|
||||||
|
match status {
|
||||||
|
P::NeedsAction => "NEEDS-ACTION",
|
||||||
|
P::Accepted => "ACCEPTED",
|
||||||
|
P::Declined => "DECLINED",
|
||||||
|
P::Tentative => "TENTATIVE",
|
||||||
|
P::Delegated => "DELEGATED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cutype_value(cutype: crate::model::CalendarUserType) -> &'static str {
|
||||||
|
use crate::model::CalendarUserType as C;
|
||||||
|
match cutype {
|
||||||
|
C::Individual => "INDIVIDUAL",
|
||||||
|
C::Group => "GROUP",
|
||||||
|
C::Resource => "RESOURCE",
|
||||||
|
C::Room => "ROOM",
|
||||||
|
C::Unknown => "UNKNOWN",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,7 @@
|
|||||||
|
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|
||||||
|
#[cfg(feature = "ical")]
|
||||||
|
pub mod ical;
|
||||||
|
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use super::datetime::IcalDuration;
|
use super::datetime::IcalDuration;
|
||||||
use super::person::Attendee;
|
use super::person::Attendee;
|
||||||
|
use super::property::UnknownProperty;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ pub struct VAlarm {
|
|||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub attendees: Vec<Attendee>,
|
pub attendees: Vec<Attendee>,
|
||||||
|
/// Alarms are where other clients keep their dismissal state —
|
||||||
|
/// `ACKNOWLEDGED`, `X-MOZ-LASTACK`, `X-EVOLUTION-ALARM-UID`. Dropping these
|
||||||
|
/// on a write would make already-dismissed alarms fire again elsewhere.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unknown_properties: Vec<UnknownProperty>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VAlarm {
|
impl VAlarm {
|
||||||
@@ -46,6 +52,7 @@ impl VAlarm {
|
|||||||
description: Some(description.into()),
|
description: Some(description.into()),
|
||||||
summary: None,
|
summary: None,
|
||||||
attendees: Vec::new(),
|
attendees: Vec::new(),
|
||||||
|
unknown_properties: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! `VCALENDAR` — RFC 5545 §3.4. The outermost container.
|
||||||
|
//!
|
||||||
|
//! Both an `.ics` feed and a single CalDAV resource are one of these. Modelling
|
||||||
|
//! it explicitly is what keeps a series and its overrides together, and what
|
||||||
|
//! keeps the `VTIMEZONE` definitions attached to the events that reference
|
||||||
|
//! them — the previous iteration parsed straight to a flat `Vec` of events and
|
||||||
|
//! lost both.
|
||||||
|
|
||||||
|
use super::event::VEvent;
|
||||||
|
use super::property::{UnknownComponent, UnknownProperty};
|
||||||
|
use super::timezone::VTimeZone;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One `VCALENDAR`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct VCalendar {
|
||||||
|
/// `PRODID` — identifies whatever wrote this. Required by the RFC.
|
||||||
|
pub prodid: String,
|
||||||
|
/// `VERSION` — `2.0` in practice, always.
|
||||||
|
pub version: String,
|
||||||
|
/// `CALSCALE`, when stated.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub calscale: Option<String>,
|
||||||
|
/// `METHOD` — `PUBLISH` on a subscribed feed, absent on a CalDAV resource.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub method: Option<String>,
|
||||||
|
/// The events inside. On a CalDAV resource these all share a `UID`; on a
|
||||||
|
/// feed they do not.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub events: Vec<VEvent>,
|
||||||
|
/// Zone definitions referenced by those events' `TZID` parameters.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub timezones: Vec<VTimeZone>,
|
||||||
|
/// Calendar-level properties we do not interpret, such as `X-WR-CALNAME`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unknown_properties: Vec<UnknownProperty>,
|
||||||
|
/// `VTODO`, `VJOURNAL` and anything else sharing the collection, kept whole
|
||||||
|
/// so writing an event back never deletes them.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unknown_components: Vec<UnknownComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Runway stamps into `PRODID` on everything it writes.
|
||||||
|
pub const RUNWAY_PRODID: &str = "-//Runway//Runway Calendar//EN";
|
||||||
|
|
||||||
|
impl VCalendar {
|
||||||
|
/// An empty calendar attributed to Runway.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
prodid: RUNWAY_PRODID.to_owned(),
|
||||||
|
version: "2.0".to_owned(),
|
||||||
|
calscale: None,
|
||||||
|
method: None,
|
||||||
|
events: Vec::new(),
|
||||||
|
timezones: Vec::new(),
|
||||||
|
unknown_properties: Vec::new(),
|
||||||
|
unknown_components: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A calendar holding the given events.
|
||||||
|
pub fn with_events(events: Vec<VEvent>) -> Self {
|
||||||
|
Self {
|
||||||
|
events,
|
||||||
|
..Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zone definition for an identifier, if this calendar carries one.
|
||||||
|
///
|
||||||
|
/// Matched case-sensitively: RFC 5545 §3.2.19 makes `TZID` values opaque,
|
||||||
|
/// and `America/New_York` is not the same string as `America/new_york`.
|
||||||
|
pub fn timezone(&self, tzid: &str) -> Option<&VTimeZone> {
|
||||||
|
self.timezones.iter().find(|tz| tz.tzid == tzid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `TZID` the events actually reference.
|
||||||
|
///
|
||||||
|
/// Used to check a calendar is self-contained before writing it back: a
|
||||||
|
/// resource that names a zone it does not define is one another client may
|
||||||
|
/// be unable to read.
|
||||||
|
pub fn referenced_tzids(&self) -> Vec<&str> {
|
||||||
|
let mut ids: Vec<&str> = self
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.flat_map(VEvent::referenced_tzids)
|
||||||
|
.collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TZID`s referenced by an event but not defined here.
|
||||||
|
pub fn undefined_tzids(&self) -> Vec<&str> {
|
||||||
|
self.referenced_tzids()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| self.timezone(id).is_none())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The series master — the event with no `RECURRENCE-ID`.
|
||||||
|
pub fn master(&self) -> Option<&VEvent> {
|
||||||
|
self.events.iter().find(|e| !e.is_override())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The overrides, each replacing one occurrence of a master.
|
||||||
|
pub fn overrides(&self) -> impl Iterator<Item = &VEvent> {
|
||||||
|
self.events.iter().filter(|e| e.is_override())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VCalendar {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use super::alarm::VAlarm;
|
use super::alarm::VAlarm;
|
||||||
use super::datetime::{CalendarDateTime, EventEnd, IcalDuration};
|
use super::datetime::{CalendarDateTime, EventEnd, IcalDuration};
|
||||||
use super::person::{Attendee, CalendarUser};
|
use super::person::{Attendee, CalendarUser};
|
||||||
|
use super::property::UnknownProperty;
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -97,6 +98,12 @@ pub struct VEvent {
|
|||||||
// ---- Sub-components ----
|
// ---- Sub-components ----
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub alarms: Vec<VAlarm>,
|
pub alarms: Vec<VAlarm>,
|
||||||
|
|
||||||
|
// ---- Passthrough ----
|
||||||
|
/// Properties this model does not interpret, carried through so an edit
|
||||||
|
/// here does not destroy another client's state. See [`UnknownProperty`].
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unknown_properties: Vec<UnknownProperty>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
@@ -137,9 +144,29 @@ impl VEvent {
|
|||||||
created: Some(now),
|
created: Some(now),
|
||||||
last_modified: Some(now),
|
last_modified: Some(now),
|
||||||
alarms: Vec::new(),
|
alarms: Vec::new(),
|
||||||
|
unknown_properties: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `TZID` this event's date-time properties name.
|
||||||
|
///
|
||||||
|
/// A `VCALENDAR` must define each of these; see
|
||||||
|
/// [`VCalendar::undefined_tzids`](super::calendar::VCalendar::undefined_tzids).
|
||||||
|
pub fn referenced_tzids(&self) -> Vec<&str> {
|
||||||
|
let ends = match &self.end {
|
||||||
|
Some(EventEnd::DateTime { dtend }) => Some(dtend),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
std::iter::once(&self.dtstart)
|
||||||
|
.chain(ends)
|
||||||
|
.chain(self.recurrence_id.iter())
|
||||||
|
.chain(self.rdate.iter())
|
||||||
|
.chain(self.exdate.iter())
|
||||||
|
.filter_map(|dt| dt.tzid())
|
||||||
|
.map(|tz| tz.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The title to display. Empty summaries are treated as absent.
|
/// The title to display. Empty summaries are treated as absent.
|
||||||
pub fn title(&self) -> Option<&str> {
|
pub fn title(&self) -> Option<&str> {
|
||||||
self.summary.as_deref().filter(|s| !s.trim().is_empty())
|
self.summary.as_deref().filter(|s| !s.trim().is_empty())
|
||||||
|
|||||||
@@ -6,12 +6,16 @@
|
|||||||
//! or the recurrence engine into the WASM bundle.
|
//! or the recurrence engine into the WASM bundle.
|
||||||
|
|
||||||
mod alarm;
|
mod alarm;
|
||||||
|
mod calendar;
|
||||||
mod datetime;
|
mod datetime;
|
||||||
mod event;
|
mod event;
|
||||||
mod object;
|
mod object;
|
||||||
mod person;
|
mod person;
|
||||||
|
mod property;
|
||||||
|
mod timezone;
|
||||||
|
|
||||||
pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm};
|
pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm};
|
||||||
|
pub use calendar::{RUNWAY_PRODID, VCalendar};
|
||||||
pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId};
|
pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId};
|
||||||
pub use event::{
|
pub use event::{
|
||||||
EventClass, EventStatus, GeoPosition, InvalidPriority, Priority, PriorityBand, Transparency,
|
EventClass, EventStatus, GeoPosition, InvalidPriority, Priority, PriorityBand, Transparency,
|
||||||
@@ -19,3 +23,5 @@ pub use event::{
|
|||||||
};
|
};
|
||||||
pub use object::{CalendarObject, EditScope};
|
pub use object::{CalendarObject, EditScope};
|
||||||
pub use person::{Attendee, CalendarUser, CalendarUserType, ParticipationStatus, Role};
|
pub use person::{Attendee, CalendarUser, CalendarUserType, ParticipationStatus, Role};
|
||||||
|
pub use property::{PropertyParam, UnknownComponent, UnknownProperty};
|
||||||
|
pub use timezone::{InvalidUtcOffset, TimeZoneRule, TimeZoneRuleKind, UtcOffset, VTimeZone};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! were added to "clean up" the result by silently discarding events. Keeping
|
//! were added to "clean up" the result by silently discarding events. Keeping
|
||||||
//! the resource intact removes the need for any of that.
|
//! the resource intact removes the need for any of that.
|
||||||
|
|
||||||
|
use super::calendar::VCalendar;
|
||||||
use super::event::VEvent;
|
use super::event::VEvent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -25,35 +26,43 @@ pub struct CalendarObject {
|
|||||||
pub etag: Option<String>,
|
pub etag: Option<String>,
|
||||||
/// Path of the collection holding this resource.
|
/// Path of the collection holding this resource.
|
||||||
pub calendar_path: String,
|
pub calendar_path: String,
|
||||||
/// The `VEVENT`s inside, sharing one `UID`: at most one master plus any
|
/// The `VCALENDAR` this resource contains — events, and the zone
|
||||||
/// number of overrides.
|
/// definitions they reference. Both halves have to travel together: a `PUT`
|
||||||
pub events: Vec<VEvent>,
|
/// that dropped the `VTIMEZONE` would leave the resource unreadable to
|
||||||
|
/// clients that cannot resolve the identifier on their own.
|
||||||
|
pub calendar: VCalendar,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalendarObject {
|
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`.
|
/// The series master — the event without a `RECURRENCE-ID`.
|
||||||
///
|
///
|
||||||
/// Absent when a resource contains only overrides, which is unusual but
|
/// Absent when a resource contains only overrides, which is unusual but
|
||||||
/// legal and which servers do emit.
|
/// legal and which servers do emit.
|
||||||
pub fn master(&self) -> Option<&VEvent> {
|
pub fn master(&self) -> Option<&VEvent> {
|
||||||
self.events.iter().find(|e| !e.is_override())
|
self.calendar.master()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The overrides, each replacing one occurrence of the master.
|
/// The overrides, each replacing one occurrence of the master.
|
||||||
pub fn overrides(&self) -> impl Iterator<Item = &VEvent> {
|
pub fn overrides(&self) -> impl Iterator<Item = &VEvent> {
|
||||||
self.events.iter().filter(|e| e.is_override())
|
self.calendar.overrides()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `UID` shared by every event in this resource.
|
/// The `UID` shared by every event in this resource.
|
||||||
pub fn uid(&self) -> Option<&str> {
|
pub fn uid(&self) -> Option<&str> {
|
||||||
self.events.first().map(|e| e.uid.as_str())
|
self.calendar.events.first().map(|e| e.uid.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether every contained event agrees on the `UID`, as RFC 4791 requires.
|
/// Whether every contained event agrees on the `UID`, as RFC 4791 requires.
|
||||||
///
|
///
|
||||||
/// Worth asserting in tests against real servers rather than assuming.
|
/// Worth asserting in tests against real servers rather than assuming.
|
||||||
pub fn has_consistent_uid(&self) -> bool {
|
pub fn has_consistent_uid(&self) -> bool {
|
||||||
let mut uids = self.events.iter().map(|e| e.uid.as_str());
|
let mut uids = self.calendar.events.iter().map(|e| e.uid.as_str());
|
||||||
match uids.next() {
|
match uids.next() {
|
||||||
None => true,
|
None => true,
|
||||||
Some(first) => uids.all(|u| u == first),
|
Some(first) => uids.all(|u| u == first),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Organizers and attendees — RFC 5545 §3.8.4.
|
//! Organizers and attendees — RFC 5545 §3.8.4.
|
||||||
|
|
||||||
|
use super::property::PropertyParam;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A calendar user address plus its display parameters. Used for `ORGANIZER`.
|
/// A calendar user address plus its display parameters. Used for `ORGANIZER`.
|
||||||
@@ -19,6 +20,10 @@ pub struct CalendarUser {
|
|||||||
/// `LANGUAGE`.
|
/// `LANGUAGE`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
/// Parameters this model does not interpret, such as Google's
|
||||||
|
/// `X-NUM-GUESTS`. Kept so a round-trip does not quietly strip them.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unknown_params: Vec<PropertyParam>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalendarUser {
|
impl CalendarUser {
|
||||||
@@ -29,6 +34,7 @@ impl CalendarUser {
|
|||||||
dir_entry: None,
|
dir_entry: None,
|
||||||
sent_by: None,
|
sent_by: None,
|
||||||
language: None,
|
language: None,
|
||||||
|
unknown_params: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +73,9 @@ pub struct Attendee {
|
|||||||
pub sent_by: Option<String>,
|
pub sent_by: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
/// As on [`CalendarUser`]: unrecognised parameters travel through.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unknown_params: Vec<PropertyParam>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Attendee {
|
impl Attendee {
|
||||||
@@ -83,6 +92,7 @@ impl Attendee {
|
|||||||
delegated_from: Vec::new(),
|
delegated_from: Vec::new(),
|
||||||
sent_by: None,
|
sent_by: None,
|
||||||
language: None,
|
language: None,
|
||||||
|
unknown_params: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! Properties this model does not interpret, kept so they survive a round-trip.
|
||||||
|
//!
|
||||||
|
//! A calendar on a real server is written by several clients at once. Ours is
|
||||||
|
//! one of them, and it is not the authority on which properties matter:
|
||||||
|
//! Thunderbird tracks alarm dismissal in `X-MOZ-LASTACK`, Evolution keys its
|
||||||
|
//! alarms on `X-EVOLUTION-ALARM-UID`, Exchange carries a dozen
|
||||||
|
//! `X-MICROSOFT-CDO-*` flags, and `ACKNOWLEDGED` is real RFC 9074.
|
||||||
|
//!
|
||||||
|
//! If we parsed an event, dropped everything we did not recognise, and wrote it
|
||||||
|
//! back, we would silently destroy the other clients' state on every edit. So
|
||||||
|
//! anything unrecognised is carried through opaquely rather than discarded.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A property carried through verbatim.
|
||||||
|
///
|
||||||
|
/// The value is stored **exactly as it appeared on the wire**, still escaped.
|
||||||
|
/// Nothing here is interpreted, so nothing here can be misinterpreted; it goes
|
||||||
|
/// back out byte-for-byte as it came in.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UnknownProperty {
|
||||||
|
/// The property name, as written.
|
||||||
|
pub name: String,
|
||||||
|
/// Parameters in their original order, values still quoted where they were.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub params: Vec<PropertyParam>,
|
||||||
|
/// The raw, still-escaped value.
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnknownProperty {
|
||||||
|
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
params: Vec::new(),
|
||||||
|
value: value.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first value of a named parameter, if present. Case-insensitive on
|
||||||
|
/// the parameter name, as RFC 5545 §3.2 requires.
|
||||||
|
pub fn param(&self, name: &str) -> Option<&str> {
|
||||||
|
self.params
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name.eq_ignore_ascii_case(name))
|
||||||
|
.map(|p| p.value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A whole component carried through verbatim.
|
||||||
|
///
|
||||||
|
/// Runway is a `VEVENT` client, but a calendar collection may also hold
|
||||||
|
/// `VTODO`, `VJOURNAL` or `VFREEBUSY` components — the `Personal` collection on
|
||||||
|
/// a typical Baikal advertises all three. Writing a resource back without them
|
||||||
|
/// would delete another application's data, so they travel through untouched.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UnknownComponent {
|
||||||
|
/// The component name, such as `VTODO`.
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub properties: Vec<UnknownProperty>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub components: Vec<UnknownComponent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `name=value` parameter of a property.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PropertyParam {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PropertyParam {
|
||||||
|
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
value: value.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Golden-file corpus
|
||||||
|
|
||||||
|
Every file here is real iCalendar data unless it sits under `synthetic/`. The point of the corpus
|
||||||
|
is that `runway-core` is tested against what servers and clients actually emit, not against what
|
||||||
|
the RFC says they should.
|
||||||
|
|
||||||
|
The contract each file must satisfy is in `tests/ical.rs`: **parse → serialise → parse must be
|
||||||
|
stable**, and the second parse must equal the first. A file that cannot round-trip is a bug in the
|
||||||
|
parser or the writer, never a reason to edit the file.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
`baikal/` — captured from a live Baikal (SabreDAV) server holding six calendars written by seven
|
||||||
|
different clients over five years. One file per CalDAV resource, exactly as the server returned it
|
||||||
|
in a `calendar-query` REPORT.
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| `davx5-allday-weekly-alarm.ics` | `VALUE=DATE` all-day series, `RRULE`, one `VALARM` |
|
||||||
|
| `davx5-zoned-two-alarms-vtimezone.ics` | `TZID=America/New_York`, **two** `VALARM`s, `VTIMEZONE` emitted *after* the `VEVENT` |
|
||||||
|
| `evolution-x-lic-error.ics` | `X-LIC-ERROR` baked in by libical, `ACKNOWLEDGED`, `X-EVOLUTION-ALARM-UID`, `TRIGGER;RELATED=START` |
|
||||||
|
| `thunderbird-google-invite-attendees.ics` | three `ATTENDEE`s with `CUTYPE`/`PARTSTAT`/`X-NUM-GUESTS`, `RRULE` with `COUNT`, `Europe/Zurich` |
|
||||||
|
| `thunderbird-x-moz-props.ics` | `X-MOZ-GENERATION`, `X-MOZ-LASTACK` — vendor state that must survive a round-trip |
|
||||||
|
| `outlook-invite-quoted-cn.ics` | `ORGANIZER`/`ATTENDEE` with a **quoted `CN` containing a comma**, `LANGUAGE` parameters, `X-MICROSOFT-CDO-*` |
|
||||||
|
| `runway-v1-unfolded-line.ics` | **v1's own broken output**, still stored on the server: a 248-octet unfolded `DESCRIPTION` (RFC violation) and `CREATED` with no `Z` |
|
||||||
|
|
||||||
|
`outlook/` — captured from a published Outlook/Exchange `.ics` feed (252 events, 103 UIDs), trimmed
|
||||||
|
to seven components that carry the interesting structure.
|
||||||
|
|
||||||
|
| Covers |
|
||||||
|
|---|
|
||||||
|
| **Windows timezone names** (`TZID=Pacific Standard Time`), not IANA — with the matching `VTIMEZONE` definitions, whose `DTSTART:16010101T020000` is year **1601** |
|
||||||
|
| A master with four `RECURRENCE-ID` overrides, one of which moves the occurrence to a **different day** |
|
||||||
|
| An **orphan override**: a `RECURRENCE-ID` whose master lies outside the feed window and must still render |
|
||||||
|
| `EXDATE` with **many comma-separated values on one line** |
|
||||||
|
| `METHOD:PUBLISH`, `X-WR-CALNAME`, and the full `X-MICROSOFT-*` set |
|
||||||
|
|
||||||
|
`synthetic/` — hand-authored, because nothing in the real corpus exercises these: `DURATION`
|
||||||
|
instead of `DTEND`, floating (zoneless) times, `RDATE` on an event, a CalDAV resource bundling a
|
||||||
|
master with its override, a DST spring-forward series, a leap-day series, and the complete set of
|
||||||
|
RFC 5545 text escapes with non-ASCII.
|
||||||
|
|
||||||
|
## Scrubbing
|
||||||
|
|
||||||
|
The real files have been scrubbed. Structure is preserved **exactly** — property names, parameters,
|
||||||
|
property order, component nesting, every datetime, `TZID`, `RRULE`, `EXDATE`, `RECURRENCE-ID`,
|
||||||
|
`SEQUENCE` and `X-` property is byte-for-byte what the server sent. Only human-readable content was
|
||||||
|
replaced: `SUMMARY`, `DESCRIPTION`, `LOCATION`, display names, email addresses and URLs, each with
|
||||||
|
synthetic text of comparable shape that keeps the RFC 5545 escaping in play. `UID`s were replaced by
|
||||||
|
a stable hash that preserves their original *format*, so master/override linkage inside a series
|
||||||
|
still holds.
|
||||||
|
|
||||||
|
`runway-v1-unfolded-line.ics` is deliberately **not** refolded: its over-long line is the defect
|
||||||
|
under test.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:DAVx5/4.4.8-gplay ical4j/3.2.19 (com.digibites.calendar)
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:eee51914-187b-40d5-342c-dc80c118438a
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SUMMARY:Check-in
|
||||||
|
CLASS:PUBLIC
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
DTSTART;VALUE=DATE:20250331
|
||||||
|
DTEND;VALUE=DATE:20250401
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
CREATED:20250902T164854Z
|
||||||
|
DTSTAMP:20250902T164854Z
|
||||||
|
LAST-MODIFIED:20250902T164854Z
|
||||||
|
SEQUENCE:2
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Retrospective demo retro
|
||||||
|
TRIGGER:-PT4H
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:DAVx5/4.5.19-gplay ical4j/4.3.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTAMP:20260811T142404Z
|
||||||
|
UID:3a21fd46-26c4-85b5-eee3-6d2d2256f8ef
|
||||||
|
SUMMARY:Briefing
|
||||||
|
DTSTART;TZID=America/New_York:20260818T083000
|
||||||
|
DTEND;TZID=America/New_York:20260818T093000
|
||||||
|
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
BEGIN:VALARM
|
||||||
|
TRIGGER:-PT1H
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Design
|
||||||
|
END:VALARM
|
||||||
|
BEGIN:VALARM
|
||||||
|
TRIGGER:-PT12H
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Design
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:America/New_York
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZNAME:EST
|
||||||
|
TZOFFSETFROM:-0400
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
DTSTART:20071104T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZNAME:EDT
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0400
|
||||||
|
DTSTART:20070311T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:America/Denver
|
||||||
|
X-LIC-LOCATION:America/Denver
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZNAME:MDT
|
||||||
|
TZOFFSETFROM:-0700
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
DTSTART:20070311T020000
|
||||||
|
RRULE:FREQ=YEARLY;UNTIL=20370308T090000Z;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZNAME:MST
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0700
|
||||||
|
DTSTART:20071104T020000
|
||||||
|
RRULE:FREQ=YEARLY;UNTIL=20361102T080000Z;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20210128T161146Z
|
||||||
|
LAST-MODIFIED:20211019T060028Z
|
||||||
|
DTSTAMP:20210128T203012Z
|
||||||
|
UID:c615f0a3-b567-cd71-7925-216aa5c3cc06
|
||||||
|
SUMMARY:Handoff
|
||||||
|
X-MOZ-LASTACK:20210128T203012Z
|
||||||
|
DTSTART;TZID=America/Denver:20210128T133000
|
||||||
|
DTEND;TZID=America/Denver:20210128T143000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
X-MOZ-GENERATION:2
|
||||||
|
BEGIN:VALARM
|
||||||
|
X-EVOLUTION-ALARM-UID:799e9f4195fcc29e5cc98f57eda3937d400a3543
|
||||||
|
ACTION:DISPLAY
|
||||||
|
DESCRIPTION:Check-in retro sync
|
||||||
|
TRIGGER;RELATED=START:-PT15M
|
||||||
|
ACKNOWLEDGED:20211019T060028Z
|
||||||
|
X-LIC-ERROR;X-LIC-ERRORTYPE=PARAMETER-VALUE-PARSE-ERROR:Got a VALUE paramet
|
||||||
|
er with an illegal type for property: VALUE=DURATION
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:America/Denver
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:-0700
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
TZNAME:MDT
|
||||||
|
DTSTART:19700308T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0700
|
||||||
|
TZNAME:MST
|
||||||
|
DTSTART:19701101T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
LAST-MODIFIED:20210928T163536Z
|
||||||
|
DTSTAMP:20210928T163536Z
|
||||||
|
UID:BC7787EBDF79D94DA9E1A90BFB6ECA7E9CD51D0402D0FAEEEC9CD42126A75F5DBC7787E
|
||||||
|
BDF79D94DA9E1A90BFB6ECA7E9CD51D0402D0FAEE
|
||||||
|
SUMMARY:Standup Design
|
||||||
|
PRIORITY:5
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
ORGANIZER;CN=Dana Okafor;SCHEDULE-AGENT=CLIENT:mailto:dana.okafor@example.o
|
||||||
|
rg
|
||||||
|
ATTENDEE;RSVP=TRUE;CN="Priya Raman";PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:
|
||||||
|
mailto:priya.raman@example.org
|
||||||
|
DTSTART;TZID=America/Denver:20210930T140000
|
||||||
|
DTEND;TZID=America/Denver:20210930T143000
|
||||||
|
DESCRIPTION;LANGUAGE=en-US:Quarterly handoff budget retrospective workshop
|
||||||
|
kickoff kickoff demo\nJoin: https://example.org/meet/653ba53ffa43\n
|
||||||
|
CLASS:PUBLIC
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
SEQUENCE:0
|
||||||
|
LOCATION;LANGUAGE=en-US:Virtual
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:0
|
||||||
|
X-MICROSOFT-CDO-OWNERAPPTID:-566454299
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:0
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-LOCATIONS:[]
|
||||||
|
X-MOZ-RECEIVED-SEQUENCE:0
|
||||||
|
X-MOZ-RECEIVED-DTSTAMP:20210928T141728Z
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//calendar-app//calendar-app//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:0097f029-5408-49be-e522-73f1e8e7ab35-1764105096
|
||||||
|
DTSTAMP:20251125T211136Z
|
||||||
|
DTSTART:20251203T200000Z
|
||||||
|
DTEND:20251203T203000Z
|
||||||
|
SUMMARY:Briefing
|
||||||
|
DESCRIPTION:https://gov.teams.example.org/l/meetup-join/19%3agcch%3ameeting_bc0ea8b8a3984e5c81dc505caf2c54b0%40thread.v2/0?context=%7b%22Tid%22%3a%22f50f4fcc-38ea-4525-a42e-e89136a91fed%22%2c%22Oid%22%3a%22b755cbb7-d40e-4324-b018-503bb3bfe89d%22%7d
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
CLASS:PUBLIC
|
||||||
|
CREATED:20251125T211136
|
||||||
|
LAST-MODIFIED:20251125T211136Z
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:-PT120M
|
||||||
|
DESCRIPTION:Check-in
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Zurich
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20210520T192010Z
|
||||||
|
LAST-MODIFIED:20210520T201659Z
|
||||||
|
DTSTAMP:20210520T201659Z
|
||||||
|
UID:4d11f4742abb8ae6e6ab54d8b3@google.com
|
||||||
|
SUMMARY:Review Briefing
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
ORGANIZER;CN=Jordan Vale;SCHEDULE-AGENT=CLIENT:mailto:jordan.vale@example.o
|
||||||
|
rg
|
||||||
|
ATTENDEE;RSVP=TRUE;CN=Jordan Vale;PARTSTAT=ACCEPTED;CUTYPE=INDIVIDUAL;ROLE=
|
||||||
|
REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:jordan.vale@example.org
|
||||||
|
ATTENDEE;RSVP=TRUE;CN=Priya Raman;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL;R
|
||||||
|
OLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:priya.raman@example.org
|
||||||
|
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:priya.raman@example.
|
||||||
|
org
|
||||||
|
RRULE:FREQ=WEEKLY;COUNT=5;BYDAY=TU,TH
|
||||||
|
DTSTART;TZID=Europe/Zurich:20210520T210000
|
||||||
|
DTEND;TZID=Europe/Zurich:20210520T213000
|
||||||
|
X-MICROSOFT-CDO-OWNERAPPTID:-786089143
|
||||||
|
DESCRIPTION:Join: https://example.org/meet/75a54b52f57c\nJoin: https://exam
|
||||||
|
ple.org/meet/e93ac4d1f299\nRetrospective retro onboarding design planning
|
||||||
|
planning handoff roadmap design standup demo handoff
|
||||||
|
LOCATION:Conference Room B
|
||||||
|
SEQUENCE:0
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
X-MOZ-RECEIVED-SEQUENCE:0
|
||||||
|
X-MOZ-RECEIVED-DTSTAMP:20210520T192011Z
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:America/Denver
|
||||||
|
X-TZINFO:America/Denver[2023c]
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-065956
|
||||||
|
TZNAME:America/Denver(STD)
|
||||||
|
DTSTART:18831118T120004
|
||||||
|
RDATE:18831118T120004
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-060000
|
||||||
|
TZNAME:America/Denver(STD)
|
||||||
|
DTSTART:19181027T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19201031T020000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19180331T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19210327T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-060000
|
||||||
|
TZNAME:America/Denver(STD)
|
||||||
|
DTSTART:19210522T020000
|
||||||
|
RDATE:19210522T020000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19420209T020000
|
||||||
|
RDATE:19420209T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-060000
|
||||||
|
TZNAME:America/Denver(STD)
|
||||||
|
DTSTART:19450930T020000
|
||||||
|
RDATE:19450930T020000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19650425T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19740106T020000
|
||||||
|
RDATE:19740106T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19750223T020000
|
||||||
|
RDATE:19750223T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19760425T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:19870405T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-060000
|
||||||
|
TZNAME:America/Denver(STD)
|
||||||
|
DTSTART:19651031T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T020000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:America/Denver(DST)
|
||||||
|
DTSTART:20070311T020000
|
||||||
|
RDATE:20070311T020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-060000
|
||||||
|
TZNAME:America/Denver(STD)
|
||||||
|
DTSTART:20071104T020000
|
||||||
|
RDATE:20071104T020000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETTO:-060000
|
||||||
|
TZOFFSETFROM:-070000
|
||||||
|
TZNAME:(DST)
|
||||||
|
DTSTART:20080309T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETTO:-070000
|
||||||
|
TZOFFSETFROM:-060000
|
||||||
|
TZNAME:(STD)
|
||||||
|
DTSTART:20081102T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20230901T204357Z
|
||||||
|
LAST-MODIFIED:20230901T204400Z
|
||||||
|
DTSTAMP:20230901T204400Z
|
||||||
|
UID:c9525154-42b2-3c07-b11f-46760a4dab35
|
||||||
|
SUMMARY:Check-in Budget
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
X-MOZ-LASTACK:20230901T204400Z
|
||||||
|
DTSTART;TZID=America/Denver:20230831T180000
|
||||||
|
DTEND;TZID=America/Denver:20230831T190000
|
||||||
|
X-MOZ-GENERATION:1
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:-PT2H
|
||||||
|
DESCRIPTION:Retrospective
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
METHOD:PUBLISH
|
||||||
|
PRODID:Microsoft Exchange Server 2010
|
||||||
|
VERSION:2.0
|
||||||
|
X-WR-CALNAME:Calendar
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Central Standard Time
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Mountain Standard Time
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0700
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0700
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Pacific Standard Time
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0700
|
||||||
|
TZOFFSETTO:-0800
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:16010101T020000
|
||||||
|
TZOFFSETFROM:-0800
|
||||||
|
TZOFFSETTO:-0700
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||||
|
RRULE:FREQ=WEEKLY;UNTIL=20270825T190000Z;INTERVAL=1;BYDAY=WE;WKST=SU
|
||||||
|
EXDATE;TZID=Pacific Standard Time:20251217T120000,20251224T120000,20251231T
|
||||||
|
120000,20260513T120000,20260701T120000,20260805T120000,20260819T120000,202
|
||||||
|
60826T120000,20260902T120000,20260909T120000
|
||||||
|
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||||
|
SUMMARY:Handoff Triage
|
||||||
|
DTSTART;TZID=Pacific Standard Time:20251015T120000
|
||||||
|
DTEND;TZID=Pacific Standard Time:20251015T122500
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:29
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:1
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||||
|
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||||
|
RECURRENCE-ID;TZID=Pacific Standard Time:20251015T120000
|
||||||
|
SUMMARY:Handoff Triage
|
||||||
|
DTSTART;TZID=Pacific Standard Time:20251016T110000
|
||||||
|
DTEND;TZID=Pacific Standard Time:20251016T113000
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:29
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:3
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||||
|
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||||
|
RECURRENCE-ID;TZID=Pacific Standard Time:20251105T120000
|
||||||
|
SUMMARY:Handoff Triage
|
||||||
|
DTSTART;TZID=Pacific Standard Time:20251105T120000
|
||||||
|
DTEND;TZID=Pacific Standard Time:20251105T122500
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:29
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:3
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||||
|
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||||
|
RECURRENCE-ID;TZID=Pacific Standard Time:20251112T120000
|
||||||
|
SUMMARY:Handoff Triage
|
||||||
|
DTSTART;TZID=Pacific Standard Time:20251112T120000
|
||||||
|
DTEND;TZID=Pacific Standard Time:20251112T122500
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:29
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:3
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||||
|
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||||
|
RECURRENCE-ID;TZID=Pacific Standard Time:20251210T120000
|
||||||
|
SUMMARY:Handoff Triage
|
||||||
|
DTSTART;TZID=Pacific Standard Time:20251209T130000
|
||||||
|
DTEND;TZID=Pacific Standard Time:20251209T133000
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:29
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:3
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:Briefing\nJoin: https://example.org/meet/5934b829a167\n
|
||||||
|
UID:D5BFA26242816AE1AB2B2D7D14A1B96E3C984A142C64CCBBBF1CC42774AC46FAD5BFA26
|
||||||
|
242816AE1AB2B2D7D14A1B96E3C984A142C64CCBB
|
||||||
|
RECURRENCE-ID;TZID=Mountain Standard Time:20260526T133000
|
||||||
|
SUMMARY:Briefing Onboarding
|
||||||
|
DTSTART;TZID=Mountain Standard Time:20260526T133000
|
||||||
|
DTEND;TZID=Mountain Standard Time:20260526T140000
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:1
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:1
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:3
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DESCRIPTION:\nJoin: https://example.org/meet/5cfc4622592a\n
|
||||||
|
UID:16A4709A509B3E413F90C38AB729CEA79577D656B130C5C3251996E325F3B9AF16A4709
|
||||||
|
A509B3E413F90C38AB729CEA79577D656B130C5C3
|
||||||
|
SUMMARY:Planning Check-in
|
||||||
|
DTSTART;TZID=Central Standard Time:20260406T093000
|
||||||
|
DTEND;TZID=Central Standard Time:20260406T101500
|
||||||
|
CLASS:PUBLIC
|
||||||
|
PRIORITY:5
|
||||||
|
DTSTAMP:20260826T175427Z
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
SEQUENCE:1
|
||||||
|
LOCATION:Annex, Room 7
|
||||||
|
X-MICROSOFT-CDO-APPT-SEQUENCE:1
|
||||||
|
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
|
||||||
|
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||||
|
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||||
|
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||||
|
X-MICROSOFT-CDO-INSTTYPE:0
|
||||||
|
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||||
|
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||||
|
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||||
|
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:allday-exdate@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260101T000000Z
|
||||||
|
DTSTART;VALUE=DATE:20260105
|
||||||
|
DTEND;VALUE=DATE:20260106
|
||||||
|
SUMMARY:Bin day
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
EXDATE;VALUE=DATE:20260223,20260302
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:dst-spring-forward@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260301T090000Z
|
||||||
|
DTSTART;TZID=America/Denver:20260302T090000
|
||||||
|
DTEND;TZID=America/Denver:20260302T093000
|
||||||
|
SUMMARY:Weekly across spring forward
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:duration-not-dtend@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260114T090000Z
|
||||||
|
DTSTART;TZID=America/Denver:20260114T090000
|
||||||
|
DURATION:PT1H30M
|
||||||
|
SUMMARY:Ninety minutes expressed as a duration
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:floating@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260114T090000Z
|
||||||
|
DTSTART:20260114T090000
|
||||||
|
DTEND:20260114T100000
|
||||||
|
SUMMARY:Nine in the morning\, wherever you are
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:leap-day@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20240229T000000Z
|
||||||
|
DTSTART;VALUE=DATE:20240229
|
||||||
|
DTEND;VALUE=DATE:20240301
|
||||||
|
SUMMARY:Leap day
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:rdate-exdate@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260105T090000Z
|
||||||
|
DTSTART;TZID=America/Denver:20260105T090000
|
||||||
|
DTEND;TZID=America/Denver:20260105T093000
|
||||||
|
SUMMARY:Weekly with an extra date and two skipped
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8
|
||||||
|
RDATE;TZID=America/Denver:20260110T090000
|
||||||
|
EXDATE;TZID=America/Denver:20260119T090000,20260202T090000
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:series-override@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260105T090000Z
|
||||||
|
DTSTART;TZID=America/Denver:20260105T090000
|
||||||
|
DTEND;TZID=America/Denver:20260105T100000
|
||||||
|
SUMMARY:Standup
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
SEQUENCE:0
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:series-override@fixtures.runway.invalid
|
||||||
|
RECURRENCE-ID;TZID=America/Denver:20260119T090000
|
||||||
|
DTSTAMP:20260112T142200Z
|
||||||
|
DTSTART;TZID=America/Denver:20260119T140000
|
||||||
|
DTEND;TZID=America/Denver:20260119T150000
|
||||||
|
SUMMARY:Standup (moved to the afternoon)
|
||||||
|
SEQUENCE:1
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Runway//Runway Test Fixture//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:escapes@fixtures.runway.invalid
|
||||||
|
DTSTAMP:20260114T090000Z
|
||||||
|
DTSTART;TZID=Europe/Zurich:20260114T090000
|
||||||
|
DTEND;TZID=Europe/Zurich:20260114T100000
|
||||||
|
SUMMARY:Lezione d'italiano — caffè\, cioccolato e “virgolette”
|
||||||
|
DESCRIPTION:Line one\nLine two\; with a semicolon\nA backslash: \\\nA comm
|
||||||
|
a\, and a colon: here
|
||||||
|
LOCATION;LANGUAGE=it-CH:Zürich\, Hauptbahnhof
|
||||||
|
ORGANIZER;CN="Rossi, Giulia (IT: Milano)":mailto:giulia.rossi@example.org
|
||||||
|
CATEGORIES:Lezioni,Personale
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
//! The iCalendar round-trip, exercised against real server data.
|
||||||
|
//!
|
||||||
|
//! The corpus in `tests/golden/` is described in its own README. The contract
|
||||||
|
//! asserted here is that **parse → write → parse is stable**: whatever the
|
||||||
|
//! first parse understood, the second must understand identically. A file that
|
||||||
|
//! fails is a defect in the parser or the writer, never a reason to edit the
|
||||||
|
//! file.
|
||||||
|
//!
|
||||||
|
//! Stability alone is not enough, though — a parser that dropped `ATTENDEE`
|
||||||
|
//! entirely would round-trip perfectly and still be wrong. So the structural
|
||||||
|
//! checks below assert that nothing *disappears*, and the named tests pin the
|
||||||
|
//! specific things real producers do that the previous iteration got wrong.
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use runway_core::ical::{self, IcalError};
|
||||||
|
use runway_core::model::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ corpus --
|
||||||
|
|
||||||
|
fn golden_dir() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `.ics` in the corpus, as (relative name, contents).
|
||||||
|
fn corpus() -> Vec<(String, String)> {
|
||||||
|
fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) {
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||||
|
.expect("golden corpus is readable")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|e| e.path())
|
||||||
|
.collect();
|
||||||
|
entries.sort();
|
||||||
|
for path in entries {
|
||||||
|
if path.is_dir() {
|
||||||
|
walk(&path, root, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "ics") {
|
||||||
|
let name = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
out.push((name, std::fs::read_to_string(&path).unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root = golden_dir();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
walk(&root, &root, &mut out);
|
||||||
|
assert!(!out.is_empty(), "the golden corpus must not be empty");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(name: &str) -> VCalendar {
|
||||||
|
let path = golden_dir().join(name);
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
ical::parse(&text).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts every property name in a document, at any depth.
|
||||||
|
fn property_names(ics: &str) -> BTreeMap<String, usize> {
|
||||||
|
let unfolded = ics.replace("\r\n ", "").replace("\n ", "");
|
||||||
|
let mut counts = BTreeMap::new();
|
||||||
|
for line in unfolded.lines() {
|
||||||
|
let Some((head, _)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = head.split(';').next().unwrap_or(head).to_uppercase();
|
||||||
|
if name == "BEGIN" || name == "END" || name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*counts.entry(name).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- the round-trip --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_golden_file_parses() {
|
||||||
|
for (name, text) in corpus() {
|
||||||
|
if let Err(e) = ical::parse(&text) {
|
||||||
|
panic!("{name} failed to parse: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_golden_file_survives_a_round_trip() {
|
||||||
|
for (name, text) in corpus() {
|
||||||
|
let first = ical::parse(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
let written = ical::write(&first);
|
||||||
|
let second = ical::parse(&written)
|
||||||
|
.unwrap_or_else(|e| panic!("{name} did not survive being written: {e}\n{written}"));
|
||||||
|
assert_eq!(first, second, "{name} changed meaning on a round trip");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writing_is_deterministic() {
|
||||||
|
for (name, text) in corpus() {
|
||||||
|
let parsed = ical::parse(&text).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ical::write(&parsed),
|
||||||
|
ical::write(&parsed),
|
||||||
|
"{name}: the same calendar must always produce the same bytes, or \
|
||||||
|
every read would look like a remote edit to CalDAV",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_property_is_dropped_on_a_round_trip() {
|
||||||
|
for (name, text) in corpus() {
|
||||||
|
let written = ical::write(&ical::parse(&text).unwrap());
|
||||||
|
let (before, after) = (property_names(&text), property_names(&written));
|
||||||
|
|
||||||
|
for (property, count) in &before {
|
||||||
|
// DTEND and DURATION are alternatives; a producer may send both and
|
||||||
|
// we deliberately keep only the explicit end.
|
||||||
|
if property == "DURATION" && before.contains_key("DTEND") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kept = after.get(property).copied().unwrap_or(0);
|
||||||
|
assert!(
|
||||||
|
kept >= *count,
|
||||||
|
"{name}: {property} appeared {count} time(s) in the source but \
|
||||||
|
{kept} time(s) after a round trip -- a parse that silently \
|
||||||
|
drops properties is exactly what produced the phantom \
|
||||||
|
duplicates in the last iteration",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- folding --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_is_folded_to_75_octets() {
|
||||||
|
for (name, text) in corpus() {
|
||||||
|
let written = ical::write(&ical::parse(&text).unwrap());
|
||||||
|
for (n, line) in written.split("\r\n").enumerate() {
|
||||||
|
assert!(
|
||||||
|
line.len() <= 75,
|
||||||
|
"{name} line {}: {} octets, over the RFC 5545 limit\n{line}",
|
||||||
|
n + 1,
|
||||||
|
line.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_uses_crlf_line_endings() {
|
||||||
|
for (name, text) in corpus() {
|
||||||
|
let written = ical::write(&ical::parse(&text).unwrap());
|
||||||
|
assert!(
|
||||||
|
!written.replace("\r\n", "").contains('\n'),
|
||||||
|
"{name}: every line ending must be CRLF",
|
||||||
|
);
|
||||||
|
assert!(written.ends_with("END:VCALENDAR\r\n"), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_line_is_folded_and_reads_back_identically() {
|
||||||
|
// v1 wrote this DESCRIPTION as a single 248-octet line, in violation of
|
||||||
|
// RFC 5545 3.1, and those events are still on the server.
|
||||||
|
let calendar = load("baikal/runway-v1-unfolded-line.ics");
|
||||||
|
let description = calendar.events[0].description.clone().unwrap();
|
||||||
|
assert!(description.len() > 200);
|
||||||
|
|
||||||
|
let written = ical::write(&calendar);
|
||||||
|
assert!(
|
||||||
|
written.contains("\r\n "),
|
||||||
|
"a 248-octet value must come back folded",
|
||||||
|
);
|
||||||
|
let reparsed = ical::parse(&written).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reparsed.events[0].description.as_deref(),
|
||||||
|
Some(description.as_str()),
|
||||||
|
"unfolding must reconstruct the value exactly",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folding_never_splits_a_utf8_character() {
|
||||||
|
let mut event = VEvent::new(CalendarDateTime::Utc {
|
||||||
|
utc: chrono::Utc::now(),
|
||||||
|
});
|
||||||
|
event.description = Some("é".repeat(200));
|
||||||
|
|
||||||
|
let written = ical::write(&VCalendar::with_events(vec![event.clone()]));
|
||||||
|
// Reaching this point at all means every fold landed on a boundary; a bad
|
||||||
|
// split would have produced invalid UTF-8 rather than a String.
|
||||||
|
for line in written.split("\r\n") {
|
||||||
|
assert!(line.len() <= 75);
|
||||||
|
}
|
||||||
|
let reparsed = ical::parse(&written).unwrap();
|
||||||
|
assert_eq!(reparsed.events[0].description, event.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- timezones --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn windows_timezone_identifiers_are_preserved_verbatim() {
|
||||||
|
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||||
|
|
||||||
|
let ids: Vec<&str> = calendar
|
||||||
|
.timezones
|
||||||
|
.iter()
|
||||||
|
.map(|tz| tz.tzid.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
ids.contains(&"Pacific Standard Time"),
|
||||||
|
"got {ids:?} -- Exchange names its zones in Windows form, and \
|
||||||
|
normalising to IANA at parse time would make the document \
|
||||||
|
unrepresentable; the mapping belongs at the point of use",
|
||||||
|
);
|
||||||
|
|
||||||
|
let start = &calendar.events[0].dtstart;
|
||||||
|
assert_eq!(
|
||||||
|
start.tzid().map(TzId::as_str),
|
||||||
|
Some("Pacific Standard Time"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
calendar.undefined_tzids().is_empty(),
|
||||||
|
"the feed defines its own zones"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_timezone_definition_carries_its_transition_rules() {
|
||||||
|
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||||
|
let tz = calendar.timezone("Pacific Standard Time").unwrap();
|
||||||
|
|
||||||
|
assert!(tz.has_windows_style_id());
|
||||||
|
assert_eq!(tz.rules.len(), 2, "a STANDARD and a DAYLIGHT block");
|
||||||
|
|
||||||
|
let standard = tz
|
||||||
|
.rules
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.kind == TimeZoneRuleKind::Standard)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(standard.offset_from.seconds(), -7 * 3600);
|
||||||
|
assert_eq!(standard.offset_to.seconds(), -8 * 3600);
|
||||||
|
assert!(standard.rrule.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
standard.dtstart.date().to_string(),
|
||||||
|
"1601-01-01",
|
||||||
|
"Exchange really does write the year 1601 here; a parser that \
|
||||||
|
range-checks the year rejects real data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zoned_start_is_not_promoted_to_utc() {
|
||||||
|
let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
calendar.events[0].dtstart.tzid().map(TzId::as_str),
|
||||||
|
Some("America/New_York"),
|
||||||
|
"the previous parser commented 'if no TZID, treat as UTC' and applied \
|
||||||
|
that reasoning widely enough to drift recurring events across DST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_floating_time_stays_floating() {
|
||||||
|
let calendar = load("synthetic/floating-time.ics");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
calendar.events[0].dtstart,
|
||||||
|
CalendarDateTime::Floating { .. }
|
||||||
|
),
|
||||||
|
"a value with neither Z nor TZID means local wall-clock time wherever \
|
||||||
|
it is read, which is not the same as UTC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- recurrence --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_master_and_its_overrides_are_kept_apart() {
|
||||||
|
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||||
|
|
||||||
|
let masters = calendar.events.iter().filter(|e| !e.is_override()).count();
|
||||||
|
let overrides = calendar.overrides().count();
|
||||||
|
assert!(overrides >= 4, "got {overrides} overrides");
|
||||||
|
assert!(masters >= 2);
|
||||||
|
|
||||||
|
// Grouping by UID is the whole point: several events sharing a summary and
|
||||||
|
// a UID are one series, not duplicates to be merged away.
|
||||||
|
let series_uid = calendar
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.rrule.is_some())
|
||||||
|
.map(|e| e.uid.clone())
|
||||||
|
.unwrap();
|
||||||
|
let in_series = calendar
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.uid == series_uid)
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
in_series > 1,
|
||||||
|
"the same UID appearing repeatedly is a series with exceptions",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_override_can_move_its_occurrence_to_another_day() {
|
||||||
|
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||||
|
|
||||||
|
let moved = calendar
|
||||||
|
.overrides()
|
||||||
|
.find(|e| {
|
||||||
|
e.recurrence_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|rid| rid.date() != e.dtstart.date())
|
||||||
|
})
|
||||||
|
.expect("the corpus contains a rescheduled occurrence");
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
moved.recurrence_id.as_ref().map(CalendarDateTime::date),
|
||||||
|
Some(moved.dtstart.date()),
|
||||||
|
"RECURRENCE-ID identifies which occurrence is replaced, not when the \
|
||||||
|
replacement happens -- conflating the two is how a moved meeting ends \
|
||||||
|
up rendered on its original day",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_override_without_its_master_is_still_parsed() {
|
||||||
|
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||||
|
|
||||||
|
let orphans: Vec<&VEvent> = calendar
|
||||||
|
.overrides()
|
||||||
|
.filter(|e| {
|
||||||
|
!calendar
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.uid == e.uid && !m.is_override())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!orphans.is_empty(),
|
||||||
|
"a published feed truncates series at the window edge, leaving \
|
||||||
|
overrides whose master is outside it; those are real events and \
|
||||||
|
discarding them loses data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_exdate_on_one_line_is_kept() {
|
||||||
|
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||||
|
|
||||||
|
let master = calendar
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.find(|e| !e.exdate.is_empty())
|
||||||
|
.expect("the corpus has a series with exclusions");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
master.exdate.len() >= 8,
|
||||||
|
"got {} EXDATEs -- Exchange writes them comma-separated on a single \
|
||||||
|
line, and a parser keyed on property name alone keeps only one",
|
||||||
|
master.exdate.len(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
master.exdate.iter().all(|d| d.tzid().is_some()),
|
||||||
|
"the TZID parameter applies to every value on the line",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rdate_and_exdate_coexist() {
|
||||||
|
let calendar = load("synthetic/rdate-and-exdate.ics");
|
||||||
|
let event = &calendar.events[0];
|
||||||
|
|
||||||
|
assert_eq!(event.rdate.len(), 1);
|
||||||
|
assert_eq!(event.exdate.len(), 2);
|
||||||
|
assert!(event.is_recurring());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_all_day_series_keeps_date_valued_exdates() {
|
||||||
|
let calendar = load("synthetic/allday-exdate-value-date.ics");
|
||||||
|
let event = &calendar.events[0];
|
||||||
|
|
||||||
|
assert!(event.is_all_day());
|
||||||
|
assert_eq!(event.exdate.len(), 2);
|
||||||
|
assert!(
|
||||||
|
event.exdate.iter().all(CalendarDateTime::is_date_only),
|
||||||
|
"an exclusion from an all-day series is a date, not a date-time",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_master_and_override_in_one_resource_stay_together() {
|
||||||
|
let calendar = load("synthetic/series-master-with-override.ics");
|
||||||
|
|
||||||
|
assert_eq!(calendar.events.len(), 2);
|
||||||
|
assert!(calendar.master().is_some());
|
||||||
|
assert_eq!(calendar.overrides().count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
calendar.events[0].uid, calendar.events[1].uid,
|
||||||
|
"one CalDAV resource, one UID",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- content --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_properties_are_all_kept() {
|
||||||
|
let calendar = load("baikal/thunderbird-google-invite-attendees.ics");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
calendar.events[0].attendees.len(),
|
||||||
|
3,
|
||||||
|
"three ATTENDEE lines must produce three attendees; collecting \
|
||||||
|
properties into a map keeps only the last, which is how the previous \
|
||||||
|
backend ended up with a 'TODO: Parse attendees properly'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attendee_parameters_survive() {
|
||||||
|
let calendar = load("baikal/thunderbird-google-invite-attendees.ics");
|
||||||
|
let attendee = &calendar.events[0].attendees[0];
|
||||||
|
|
||||||
|
assert!(attendee.common_name.is_some());
|
||||||
|
assert_eq!(attendee.role, Some(Role::ReqParticipant));
|
||||||
|
assert_eq!(attendee.rsvp, Some(true));
|
||||||
|
assert!(
|
||||||
|
attendee
|
||||||
|
.unknown_params
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.name.eq_ignore_ascii_case("X-NUM-GUESTS")),
|
||||||
|
"a parameter we do not model is still somebody's data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quoted_parameter_containing_a_comma_round_trips() {
|
||||||
|
let calendar = load("baikal/outlook-invite-quoted-cn.ics");
|
||||||
|
let organizer = calendar.events[0].organizer.as_ref().unwrap();
|
||||||
|
|
||||||
|
let name = organizer.common_name.as_deref().unwrap();
|
||||||
|
let written = ical::write(&calendar);
|
||||||
|
let reparsed = ical::parse(&written).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reparsed.events[0]
|
||||||
|
.organizer
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.common_name.as_deref()),
|
||||||
|
Some(name),
|
||||||
|
"an unquoted CN containing a comma reads back as two parameters",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_alarms_on_one_event_are_both_kept() {
|
||||||
|
let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics");
|
||||||
|
|
||||||
|
assert_eq!(calendar.events[0].alarms.len(), 2);
|
||||||
|
assert!(
|
||||||
|
calendar.events[0]
|
||||||
|
.alarms
|
||||||
|
.iter()
|
||||||
|
.all(|a| a.action == AlarmAction::Display)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vendor_state_on_an_alarm_is_carried_through() {
|
||||||
|
let calendar = load("baikal/evolution-x-lic-error.ics");
|
||||||
|
let alarm = &calendar.events[0].alarms[0];
|
||||||
|
|
||||||
|
let names: Vec<&str> = alarm
|
||||||
|
.unknown_properties
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.eq_ignore_ascii_case("ACKNOWLEDGED")),
|
||||||
|
"got {names:?} -- ACKNOWLEDGED records that a person dismissed this \
|
||||||
|
alarm; dropping it makes the alarm fire again in their other client",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.any(|n| n.eq_ignore_ascii_case("X-EVOLUTION-ALARM-UID"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_libical_error_marker_does_not_stop_the_parse() {
|
||||||
|
// 22 objects on the real server carry these, written by Evolution.
|
||||||
|
let calendar = load("baikal/evolution-x-lic-error.ics");
|
||||||
|
|
||||||
|
assert_eq!(calendar.events.len(), 1);
|
||||||
|
let written = ical::write(&calendar);
|
||||||
|
assert!(
|
||||||
|
written.contains("X-LIC-ERROR"),
|
||||||
|
"and it is carried, not swallowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vendor_properties_on_an_event_are_carried_through() {
|
||||||
|
let calendar = load("baikal/thunderbird-x-moz-props.ics");
|
||||||
|
|
||||||
|
let names: Vec<&str> = calendar.events[0]
|
||||||
|
.unknown_properties
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name.as_str())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n.starts_with("X-MOZ")),
|
||||||
|
"got {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_alarm_relative_to_the_end_stays_relative_to_the_end() {
|
||||||
|
let mut event = VEvent::new(CalendarDateTime::Utc {
|
||||||
|
utc: chrono::Utc::now(),
|
||||||
|
});
|
||||||
|
event.alarms.push(VAlarm {
|
||||||
|
trigger: AlarmTrigger::Relative {
|
||||||
|
offset: IcalDuration::minutes(-15).unwrap(),
|
||||||
|
related: TriggerRelation::End,
|
||||||
|
},
|
||||||
|
..VAlarm::display_before(IcalDuration::minutes(-15).unwrap(), "Wrap up")
|
||||||
|
});
|
||||||
|
|
||||||
|
let written = ical::write(&VCalendar::with_events(vec![event]));
|
||||||
|
assert!(written.contains("RELATED=END"), "{written}");
|
||||||
|
|
||||||
|
let reparsed = ical::parse(&written).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
reparsed.events[0].alarms[0].trigger,
|
||||||
|
AlarmTrigger::Relative {
|
||||||
|
related: TriggerRelation::End,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_escapes_and_non_ascii_survive() {
|
||||||
|
let calendar = load("synthetic/text-escapes-and-utf8.ics");
|
||||||
|
let event = &calendar.events[0];
|
||||||
|
|
||||||
|
let summary = event.summary.as_deref().unwrap();
|
||||||
|
assert!(summary.contains('—') && summary.contains("caffè"));
|
||||||
|
assert!(
|
||||||
|
summary.contains(", cioccolato"),
|
||||||
|
"an escaped comma is a comma"
|
||||||
|
);
|
||||||
|
|
||||||
|
let description = event.description.as_deref().unwrap();
|
||||||
|
assert!(description.contains('\n'), "\\n is a newline");
|
||||||
|
assert!(description.contains(';'), "\\; is a semicolon");
|
||||||
|
assert!(description.contains('\\'), "\\\\ is a backslash");
|
||||||
|
|
||||||
|
let reparsed = ical::parse(&ical::write(&calendar)).unwrap();
|
||||||
|
assert_eq!(reparsed.events[0].summary.as_deref(), Some(summary));
|
||||||
|
assert_eq!(reparsed.events[0].description.as_deref(), Some(description));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_multi_value_text_property_is_not_escaped_into_one_value() {
|
||||||
|
let mut event = VEvent::new(CalendarDateTime::Date {
|
||||||
|
date: chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
|
||||||
|
});
|
||||||
|
event.categories = vec!["Work".to_owned(), "Personal".to_owned()];
|
||||||
|
|
||||||
|
let written = ical::write(&VCalendar::with_events(vec![event]));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
written.contains("CATEGORIES:Work,Personal"),
|
||||||
|
"the separator must stay bare. Escaping the whole joined value would \
|
||||||
|
still round-trip through our own parser, and every other client would \
|
||||||
|
read one category named \"Work,Personal\"\n{written}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ timing --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_duration_is_kept_as_a_duration() {
|
||||||
|
let calendar = load("synthetic/duration-not-dtend.ics");
|
||||||
|
let event = &calendar.events[0];
|
||||||
|
|
||||||
|
assert!(matches!(event.end, Some(EventEnd::Duration { .. })));
|
||||||
|
assert_eq!(event.duration(), chrono::TimeDelta::minutes(90));
|
||||||
|
assert!(
|
||||||
|
ical::write(&calendar).contains("DURATION:PT1H30M"),
|
||||||
|
"a producer that sent DURATION should get DURATION back",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_all_day_value_stays_date_valued() {
|
||||||
|
let calendar = load("baikal/davx5-allday-weekly-alarm.ics");
|
||||||
|
let event = &calendar.events[0];
|
||||||
|
|
||||||
|
assert!(event.is_all_day());
|
||||||
|
assert!(ical::write(&calendar).contains("DTSTART;VALUE=DATE:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_utc_timestamp_missing_its_z_is_repaired_on_write() {
|
||||||
|
// Runway v1 wrote CREATED without the Z that RFC 5545 requires. Reading it
|
||||||
|
// as UTC and writing it back correctly repairs the record instead of
|
||||||
|
// propagating the defect.
|
||||||
|
let calendar = load("baikal/runway-v1-unfolded-line.ics");
|
||||||
|
assert!(calendar.events[0].created.is_some());
|
||||||
|
|
||||||
|
let written = ical::write(&calendar);
|
||||||
|
let created = written
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("CREATED:"))
|
||||||
|
.expect("CREATED is written");
|
||||||
|
assert!(created.ends_with('Z'), "{created}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ errors --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_dtstart_is_an_error_not_a_guess() {
|
||||||
|
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\
|
||||||
|
BEGIN:VEVENT\r\nUID:no-start\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||||
|
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
ical::parse(ics),
|
||||||
|
Err(IcalError::MissingProperty {
|
||||||
|
component: "VEVENT",
|
||||||
|
property: "DTSTART"
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_datetime_names_the_property_and_the_value() {
|
||||||
|
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\
|
||||||
|
BEGIN:VEVENT\r\nUID:bad\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||||
|
DTSTART:not-a-date\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
|
||||||
|
match ical::parse(ics) {
|
||||||
|
Err(IcalError::InvalidValue {
|
||||||
|
property, value, ..
|
||||||
|
}) => {
|
||||||
|
assert_eq!(property, "DTSTART");
|
||||||
|
assert_eq!(value, "not-a-date");
|
||||||
|
}
|
||||||
|
other => panic!("expected a typed error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_without_a_calendar_is_reported_as_such() {
|
||||||
|
// Prose is a syntax error; a well-formed component tree that simply has no
|
||||||
|
// VCALENDAR in it is the NoCalendar case.
|
||||||
|
assert!(matches!(
|
||||||
|
ical::parse("not a calendar"),
|
||||||
|
Err(IcalError::Syntax(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ical::parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n"),
|
||||||
|
Err(IcalError::NoCalendar),
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -443,7 +443,7 @@ fn series_with_one_override() -> CalendarObject {
|
|||||||
href: "/calendars/connor/personal/shared-uid.ics".to_owned(),
|
href: "/calendars/connor/personal/shared-uid.ics".to_owned(),
|
||||||
etag: Some("\"abc123\"".to_owned()),
|
etag: Some("\"abc123\"".to_owned()),
|
||||||
calendar_path: "/calendars/connor/personal/".to_owned(),
|
calendar_path: "/calendars/connor/personal/".to_owned(),
|
||||||
events: vec![master, moved],
|
calendar: VCalendar::with_events(vec![master, moved]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ fn a_resource_separates_its_master_from_its_overrides() {
|
|||||||
assert!(object.master().is_some());
|
assert!(object.master().is_some());
|
||||||
assert_eq!(object.overrides().count(), 1);
|
assert_eq!(object.overrides().count(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
object.events.len(),
|
object.events().len(),
|
||||||
2,
|
2,
|
||||||
"a master and its override are two VEVENTs in one resource, not a \
|
"a master and its override are two VEVENTs in one resource, not a \
|
||||||
duplicate to be deduplicated away",
|
duplicate to be deduplicated away",
|
||||||
@@ -472,7 +472,7 @@ fn every_event_in_a_resource_shares_one_uid() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_resource_with_mismatched_uids_is_detected() {
|
fn a_resource_with_mismatched_uids_is_detected() {
|
||||||
let mut object = series_with_one_override();
|
let mut object = series_with_one_override();
|
||||||
object.events[1].uid = "different".to_owned();
|
object.calendar.events[1].uid = "different".to_owned();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!object.has_consistent_uid(),
|
!object.has_consistent_uid(),
|
||||||
@@ -484,7 +484,7 @@ fn a_resource_with_mismatched_uids_is_detected() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_resource_holding_only_overrides_has_no_master() {
|
fn a_resource_holding_only_overrides_has_no_master() {
|
||||||
let mut object = series_with_one_override();
|
let mut object = series_with_one_override();
|
||||||
object.events.retain(VEvent::is_override);
|
object.calendar.events.retain(VEvent::is_override);
|
||||||
|
|
||||||
assert!(object.master().is_none());
|
assert!(object.master().is_none());
|
||||||
assert_eq!(object.overrides().count(), 1);
|
assert_eq!(object.overrides().count(), 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user