397 lines
15 KiB
Rust
397 lines
15 KiB
Rust
|
|
//! Transforming events between a source calendar and its aggregate copy.
|
||
|
|
//!
|
||
|
|
//! Mirroring is not a byte copy. The aggregate copy must be *scheduling inert* —
|
||
|
|
//! writing it must never cause a server to mail invitations or cancellations for
|
||
|
|
//! a meeting that was already invited from its source. How that is achieved
|
||
|
|
//! depends on the target backend, which is what [`SchedulingSuppression`] selects.
|
||
|
|
//!
|
||
|
|
//! Writing an edit back to the source is the inverse, with one asymmetry: the
|
||
|
|
//! demoted form has thrown away the live guest list, so the source event itself
|
||
|
|
//! is used as the donor to put it back. Otherwise editing a meeting's time in the
|
||
|
|
//! aggregate would silently drop its guests.
|
||
|
|
|
||
|
|
use crate::config::SchedulingSuppression;
|
||
|
|
use crate::ical::Calendar;
|
||
|
|
|
||
|
|
/// Records which source an aggregate copy came from.
|
||
|
|
pub const SOURCE_PROPERTY: &str = "X-CALCALIST-SOURCE";
|
||
|
|
/// Records the UID the event has in its source calendar.
|
||
|
|
pub const ORIGIN_UID_PROPERTY: &str = "X-CALCALIST-ORIGIN-UID";
|
||
|
|
/// Carries the guest list inertly when it cannot be kept as real attendees.
|
||
|
|
pub const ATTENDEES_PROPERTY: &str = "X-CALCALIST-ATTENDEES";
|
||
|
|
|
||
|
|
/// Separates the original description from the appended guest list, so the
|
||
|
|
/// addition can be found and removed again when writing an edit back.
|
||
|
|
pub const GUEST_MARKER: &str = "-- guests (calcalist) --";
|
||
|
|
|
||
|
|
/// Properties that only ever exist on an aggregate copy.
|
||
|
|
const OWN_PROPERTIES: &[&str] = &[SOURCE_PROPERTY, ORIGIN_UID_PROPERTY, ATTENDEES_PROPERTY];
|
||
|
|
|
||
|
|
/// Live scheduling properties, whose presence is what makes a server send mail.
|
||
|
|
const SCHEDULING_PROPERTIES: &[&str] = &["ATTENDEE", "ORGANIZER"];
|
||
|
|
|
||
|
|
/// Builds the aggregate copy of a source event.
|
||
|
|
pub fn to_aggregate(
|
||
|
|
source: &Calendar,
|
||
|
|
aggregate_uid: &str,
|
||
|
|
source_id: &str,
|
||
|
|
source_uid: &str,
|
||
|
|
owner: Option<&str>,
|
||
|
|
suppression: SchedulingSuppression,
|
||
|
|
) -> Calendar {
|
||
|
|
let mut mirrored = source.clone();
|
||
|
|
mirrored.set_uid(aggregate_uid);
|
||
|
|
mirrored.remove_properties("VEVENT", OWN_PROPERTIES);
|
||
|
|
mirrored.add_property("VEVENT", SOURCE_PROPERTY, source_id);
|
||
|
|
mirrored.add_property("VEVENT", ORIGIN_UID_PROPERTY, source_uid);
|
||
|
|
|
||
|
|
if suppression == SchedulingSuppression::Native {
|
||
|
|
return mirrored;
|
||
|
|
}
|
||
|
|
demote_attendees(&mut mirrored, owner);
|
||
|
|
mirrored
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Removes the live guest list, keeping its information in inert form.
|
||
|
|
fn demote_attendees(calendar: &mut Calendar, owner: Option<&str>) {
|
||
|
|
let guests: Vec<String> = calendar
|
||
|
|
.properties("VEVENT", "ATTENDEE")
|
||
|
|
.map(|attendee| {
|
||
|
|
let name = attendee
|
||
|
|
.param("CN")
|
||
|
|
.unwrap_or_else(|| address(attendee.value));
|
||
|
|
match attendee.param("PARTSTAT") {
|
||
|
|
Some(status) => format!("{name} <{}> ({status})", address(attendee.value)),
|
||
|
|
None => format!("{name} <{}>", address(attendee.value)),
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
// A meeting the owner declined must not read as busy in the aggregate.
|
||
|
|
if owner.is_some_and(|owner| declined(calendar, owner)) {
|
||
|
|
calendar.set_property("VEVENT", "TRANSP", "TRANSPARENT");
|
||
|
|
}
|
||
|
|
|
||
|
|
if !guests.is_empty() {
|
||
|
|
calendar.add_property(
|
||
|
|
"VEVENT",
|
||
|
|
ATTENDEES_PROPERTY,
|
||
|
|
&escape_text(&guests.join(", ")),
|
||
|
|
);
|
||
|
|
append_guests_to_description(calendar, &guests);
|
||
|
|
}
|
||
|
|
calendar.remove_properties("VEVENT", SCHEDULING_PROPERTIES);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Whether `owner` is an attendee who has declined.
|
||
|
|
fn declined(calendar: &Calendar, owner: &str) -> bool {
|
||
|
|
calendar.properties("VEVENT", "ATTENDEE").any(|attendee| {
|
||
|
|
address(attendee.value).eq_ignore_ascii_case(owner)
|
||
|
|
&& attendee.param("PARTSTAT") == Some("DECLINED")
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn append_guests_to_description(calendar: &mut Calendar, guests: &[String]) {
|
||
|
|
let existing = calendar
|
||
|
|
.properties("VEVENT", "DESCRIPTION")
|
||
|
|
.next()
|
||
|
|
.map(|property| property.value.to_string())
|
||
|
|
.unwrap_or_default();
|
||
|
|
let block = format!("{GUEST_MARKER}\\n{}", escape_text(&guests.join("\n")));
|
||
|
|
let combined = if existing.is_empty() {
|
||
|
|
block
|
||
|
|
} else {
|
||
|
|
format!("{existing}\\n\\n{block}")
|
||
|
|
};
|
||
|
|
calendar.set_property("VEVENT", "DESCRIPTION", &combined);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rebuilds a source-side event from an edited aggregate copy.
|
||
|
|
///
|
||
|
|
/// `donor` is the event as it currently stands in the source calendar, when
|
||
|
|
/// there is one. It supplies the properties the mirror transform removed, so an
|
||
|
|
/// edit made in the aggregate cannot silently strip a meeting's guests. Routing
|
||
|
|
/// a newly created event has no donor: nothing was stripped from it.
|
||
|
|
pub fn to_source(edited: &Calendar, donor: Option<&Calendar>, source_uid: &str) -> Calendar {
|
||
|
|
let mut restored = edited.clone();
|
||
|
|
restored.set_uid(source_uid);
|
||
|
|
restored.remove_properties("VEVENT", OWN_PROPERTIES);
|
||
|
|
strip_guest_block(&mut restored);
|
||
|
|
|
||
|
|
// If the mirror kept live attendees, the edit owns them. If it demoted them,
|
||
|
|
// they are missing here and must come back from the source.
|
||
|
|
if let Some(donor) = donor
|
||
|
|
&& restored.properties("VEVENT", "ATTENDEE").next().is_none()
|
||
|
|
{
|
||
|
|
for line in donor.property_lines("VEVENT", "ORGANIZER") {
|
||
|
|
restored.add_raw_property("VEVENT", line);
|
||
|
|
}
|
||
|
|
for line in donor.property_lines("VEVENT", "ATTENDEE") {
|
||
|
|
restored.add_raw_property("VEVENT", line);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
restored
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Removes the appended guest block from DESCRIPTION, leaving any real text.
|
||
|
|
fn strip_guest_block(calendar: &mut Calendar) {
|
||
|
|
let Some(description) = calendar
|
||
|
|
.properties("VEVENT", "DESCRIPTION")
|
||
|
|
.next()
|
||
|
|
.map(|property| property.value.to_string())
|
||
|
|
else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
let Some(index) = description.find(GUEST_MARKER) else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
// Strip exactly the separator this transform inserted. Trimming a character
|
||
|
|
// set here would eat a trailing "n" from real text ("Plan" -> "Pla").
|
||
|
|
let before = &description[..index];
|
||
|
|
let kept = before.strip_suffix("\\n\\n").unwrap_or(before).to_string();
|
||
|
|
if kept.is_empty() {
|
||
|
|
calendar.remove_properties("VEVENT", &["DESCRIPTION"]);
|
||
|
|
} else {
|
||
|
|
calendar.set_property("VEVENT", "DESCRIPTION", &kept);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Strips a `mailto:` (or other) scheme from a calendar user address.
|
||
|
|
fn address(value: &str) -> &str {
|
||
|
|
value.split_once(':').map_or(value, |(_, rest)| rest)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Escapes a value for an iCalendar TEXT property, per RFC 5545 section 3.3.11.
|
||
|
|
fn escape_text(value: &str) -> String {
|
||
|
|
let mut escaped = String::with_capacity(value.len());
|
||
|
|
for ch in value.chars() {
|
||
|
|
match ch {
|
||
|
|
'\\' => escaped.push_str(r"\\"),
|
||
|
|
';' => escaped.push_str(r"\;"),
|
||
|
|
',' => escaped.push_str(r"\,"),
|
||
|
|
'\n' => escaped.push_str(r"\n"),
|
||
|
|
_ => escaped.push(ch),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
escaped
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
const OWNER: &str = "me@example.com";
|
||
|
|
|
||
|
|
fn source_event(extra: &str) -> Calendar {
|
||
|
|
let text = format!(
|
||
|
|
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:event-1@example.com\r\nDTSTART:20260910T090000Z\r\nSUMMARY:Weekly sync\r\n{extra}BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||
|
|
);
|
||
|
|
Calendar::parse(&text).expect("fixture should parse")
|
||
|
|
}
|
||
|
|
|
||
|
|
const GUESTS: &str = concat!(
|
||
|
|
"ORGANIZER;CN=Boss:mailto:boss@example.com\r\n",
|
||
|
|
"ATTENDEE;CN=Me;PARTSTAT=ACCEPTED:mailto:me@example.com\r\n",
|
||
|
|
"ATTENDEE;CN=Them;PARTSTAT=NEEDS-ACTION:mailto:them@example.com\r\n",
|
||
|
|
);
|
||
|
|
|
||
|
|
fn mirror(source: &Calendar, suppression: SchedulingSuppression) -> Calendar {
|
||
|
|
to_aggregate(
|
||
|
|
source,
|
||
|
|
"deadbeef@calcalist",
|
||
|
|
"work",
|
||
|
|
"event-1@example.com",
|
||
|
|
Some(OWNER),
|
||
|
|
suppression,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a_mirror_records_where_it_came_from() {
|
||
|
|
let mirrored = mirror(&source_event(""), SchedulingSuppression::Native);
|
||
|
|
assert_eq!(mirrored.uid(), Some("deadbeef@calcalist"));
|
||
|
|
assert_eq!(
|
||
|
|
mirrored
|
||
|
|
.properties("VEVENT", SOURCE_PROPERTY)
|
||
|
|
.next()
|
||
|
|
.map(|p| p.value),
|
||
|
|
Some("work")
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
mirrored
|
||
|
|
.properties("VEVENT", ORIGIN_UID_PROPERTY)
|
||
|
|
.next()
|
||
|
|
.map(|p| p.value),
|
||
|
|
Some("event-1@example.com")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Google can suppress notification, so the guest list survives intact.
|
||
|
|
#[test]
|
||
|
|
fn a_suppressible_backend_keeps_real_attendees() {
|
||
|
|
let mirrored = mirror(&source_event(GUESTS), SchedulingSuppression::Native);
|
||
|
|
assert_eq!(mirrored.properties("VEVENT", "ATTENDEE").count(), 2);
|
||
|
|
assert_eq!(mirrored.properties("VEVENT", "ORGANIZER").count(), 1);
|
||
|
|
assert_eq!(mirrored.properties("VEVENT", ATTENDEES_PROPERTY).count(), 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// CalDAV cannot, so the live properties must go.
|
||
|
|
#[test]
|
||
|
|
fn an_unsuppressible_backend_emits_no_live_scheduling_properties() {
|
||
|
|
let mirrored = mirror(&source_event(GUESTS), SchedulingSuppression::None);
|
||
|
|
assert_eq!(mirrored.properties("VEVENT", "ATTENDEE").count(), 0);
|
||
|
|
assert_eq!(mirrored.properties("VEVENT", "ORGANIZER").count(), 0);
|
||
|
|
let output = mirrored.to_ics();
|
||
|
|
assert!(!output.contains("\r\nATTENDEE"));
|
||
|
|
assert!(!output.contains("\r\nORGANIZER"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn the_demoted_guest_list_is_still_readable() {
|
||
|
|
let mirrored = mirror(&source_event(GUESTS), SchedulingSuppression::None);
|
||
|
|
let inert = mirrored
|
||
|
|
.properties("VEVENT", ATTENDEES_PROPERTY)
|
||
|
|
.next()
|
||
|
|
.expect("guest list carried")
|
||
|
|
.value;
|
||
|
|
assert!(inert.contains("Me <me@example.com> (ACCEPTED)"));
|
||
|
|
assert!(inert.contains("Them <them@example.com> (NEEDS-ACTION)"));
|
||
|
|
|
||
|
|
let description = mirrored
|
||
|
|
.properties("VEVENT", "DESCRIPTION")
|
||
|
|
.next()
|
||
|
|
.expect("description added")
|
||
|
|
.value;
|
||
|
|
assert!(description.contains(GUEST_MARKER));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A meeting the owner declined should read as free, not busy.
|
||
|
|
#[test]
|
||
|
|
fn declining_marks_the_mirror_transparent() {
|
||
|
|
let declined_by_owner = concat!(
|
||
|
|
"ATTENDEE;CN=Me;PARTSTAT=DECLINED:mailto:me@example.com\r\n",
|
||
|
|
"ATTENDEE;CN=Them;PARTSTAT=ACCEPTED:mailto:them@example.com\r\n",
|
||
|
|
);
|
||
|
|
let mirrored = mirror(
|
||
|
|
&source_event(declined_by_owner),
|
||
|
|
SchedulingSuppression::None,
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
mirrored
|
||
|
|
.properties("VEVENT", "TRANSP")
|
||
|
|
.next()
|
||
|
|
.map(|p| p.value),
|
||
|
|
Some("TRANSPARENT")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Someone else declining says nothing about the owner's availability.
|
||
|
|
#[test]
|
||
|
|
fn another_guest_declining_does_not_free_the_owner() {
|
||
|
|
let other_declined = concat!(
|
||
|
|
"ATTENDEE;CN=Me;PARTSTAT=ACCEPTED:mailto:me@example.com\r\n",
|
||
|
|
"ATTENDEE;CN=Them;PARTSTAT=DECLINED:mailto:them@example.com\r\n",
|
||
|
|
);
|
||
|
|
let mirrored = mirror(&source_event(other_declined), SchedulingSuppression::None);
|
||
|
|
assert_eq!(mirrored.properties("VEVENT", "TRANSP").count(), 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn alarms_are_never_touched() {
|
||
|
|
for suppression in [SchedulingSuppression::Native, SchedulingSuppression::None] {
|
||
|
|
let mirrored = mirror(&source_event(GUESTS), suppression);
|
||
|
|
assert!(mirrored.to_ics().contains("BEGIN:VALARM"));
|
||
|
|
assert!(mirrored.to_ics().contains("TRIGGER:-PT15M"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mirroring_twice_is_stable() {
|
||
|
|
let source = source_event(GUESTS);
|
||
|
|
let once = mirror(&source, SchedulingSuppression::None);
|
||
|
|
let twice = mirror(&source, SchedulingSuppression::None);
|
||
|
|
assert_eq!(once.to_ics(), twice.to_ics());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn writing_back_restores_the_source_uid_and_drops_our_properties() {
|
||
|
|
let source = source_event(GUESTS);
|
||
|
|
let mirrored = mirror(&source, SchedulingSuppression::None);
|
||
|
|
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
|
||
|
|
|
||
|
|
assert_eq!(back.uid(), Some("event-1@example.com"));
|
||
|
|
for property in OWN_PROPERTIES {
|
||
|
|
assert_eq!(back.properties("VEVENT", property).count(), 0, "{property}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The demoted mirror has no guests to give back, so editing it must not
|
||
|
|
/// silently strip the meeting's attendees.
|
||
|
|
#[test]
|
||
|
|
fn writing_back_a_demoted_mirror_recovers_the_guest_list() {
|
||
|
|
let source = source_event(GUESTS);
|
||
|
|
let mut mirrored = mirror(&source, SchedulingSuppression::None);
|
||
|
|
mirrored.set_property("VEVENT", "SUMMARY", "Weekly sync (moved)");
|
||
|
|
|
||
|
|
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
|
||
|
|
assert_eq!(back.properties("VEVENT", "ATTENDEE").count(), 2);
|
||
|
|
assert_eq!(back.properties("VEVENT", "ORGANIZER").count(), 1);
|
||
|
|
// The edit itself survives.
|
||
|
|
assert_eq!(
|
||
|
|
back.properties("VEVENT", "SUMMARY").next().map(|p| p.value),
|
||
|
|
Some("Weekly sync (moved)")
|
||
|
|
);
|
||
|
|
// And the parameters came back with them.
|
||
|
|
let attendee = back
|
||
|
|
.properties("VEVENT", "ATTENDEE")
|
||
|
|
.find(|a| a.value.contains("them@"))
|
||
|
|
.expect("guest restored");
|
||
|
|
assert_eq!(attendee.param("PARTSTAT"), Some("NEEDS-ACTION"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn writing_back_removes_the_appended_guest_block() {
|
||
|
|
let source = source_event(GUESTS);
|
||
|
|
let mirrored = mirror(&source, SchedulingSuppression::None);
|
||
|
|
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
|
||
|
|
assert_eq!(back.properties("VEVENT", "DESCRIPTION").count(), 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a_real_description_survives_the_round_trip() {
|
||
|
|
let with_description = format!("DESCRIPTION:Bring the plan\r\n{GUESTS}");
|
||
|
|
let source = source_event(&with_description);
|
||
|
|
let mirrored = mirror(&source, SchedulingSuppression::None);
|
||
|
|
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
|
||
|
|
assert_eq!(
|
||
|
|
back.properties("VEVENT", "DESCRIPTION")
|
||
|
|
.next()
|
||
|
|
.map(|p| p.value),
|
||
|
|
Some("Bring the plan")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Regression: trimming a character set would truncate text ending in "n".
|
||
|
|
#[test]
|
||
|
|
fn a_description_ending_in_n_is_not_truncated() {
|
||
|
|
let with_description = format!("DESCRIPTION:Bring the plan\r\n{GUESTS}");
|
||
|
|
let source = source_event(&with_description);
|
||
|
|
let mirrored = mirror(&source, SchedulingSuppression::None);
|
||
|
|
let back = to_source(&mirrored, Some(&source), "event-1@example.com");
|
||
|
|
let description = back
|
||
|
|
.properties("VEVENT", "DESCRIPTION")
|
||
|
|
.next()
|
||
|
|
.expect("description kept")
|
||
|
|
.value;
|
||
|
|
assert!(
|
||
|
|
description.ends_with("plan"),
|
||
|
|
"truncated to {description:?}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn text_escaping_follows_the_specification() {
|
||
|
|
assert_eq!(escape_text("a,b;c\\d\ne"), r"a\,b\;c\\d\ne");
|
||
|
|
}
|
||
|
|
}
|