//! 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"]; /// The marker that keeps an event in the aggregate rather than filing it under /// a source. Reserved: an endpoint may not be given this id. pub const LOCAL_SINK: &str = "local"; /// Whether a routing hint asks for the event to stay where it is. pub fn is_local_sink(hint: &str) -> bool { hint.eq_ignore_ascii_case(LOCAL_SINK) } /// 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 } /// Applies a target's scheduling rule to an event that is not a mirror. /// /// An event living only in the aggregate still has to obey the rule that writes /// to an aggregate never emit scheduling mail — moving one between calendars is /// as capable of mailing a guest list as mirroring is. It carries no provenance, /// having none, so this is the demotion alone. pub fn make_inert( calendar: &Calendar, owner: Option<&str>, suppression: SchedulingSuppression, ) -> Calendar { let mut inert = calendar.clone(); if suppression == SchedulingSuppression::None { demote_attendees(&mut inert, owner); } inert } /// Removes the live guest list, keeping its information in inert form. fn demote_attendees(calendar: &mut Calendar, owner: Option<&str>) { let guests: Vec = 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); } /// Marks which source an event created in the aggregate should be filed under. /// /// A line of its own in the description, `@endpoint-id`. The description is used /// rather than the title because every calendar client exposes a notes field and /// editing it does not disfigure the event's name, and rather than CATEGORIES /// because many mobile clients do not surface categories at all — though a /// matching category is honoured too, that being the field iCalendar intends. pub const ROUTE_MARKER: char = '@'; /// The source an event asks to be filed under, if it names one. pub fn routing_hint(calendar: &Calendar) -> Option { if let Some(description) = calendar .properties("VEVENT", "DESCRIPTION") .next() .map(|property| property.value) && let Some(hint) = description_hint(description) { return Some(hint); } calendar .properties("VEVENT", "CATEGORIES") .flat_map(|property| { property .value .split(',') .map(|category| category.trim().to_string()) .collect::>() }) .find(|category| !category.is_empty()) } /// Finds a line consisting only of `@name`. Requiring the whole line keeps an /// address written in prose from being mistaken for a routing instruction. fn description_hint(description: &str) -> Option { description .split("\\n") .map(str::trim) .find_map(|line| line.strip_prefix(ROUTE_MARKER)) .filter(|name| !name.is_empty() && !name.contains(char::is_whitespace)) .map(str::to_string) } /// Removes the routing instruction, which is calcalist's own bookkeeping and has /// no business appearing in the calendar the event lands in. pub fn strip_routing_hint(calendar: &mut Calendar, hint: &str) { let categories: Vec = calendar .properties("VEVENT", "CATEGORIES") .map(|property| property.value.to_string()) .collect(); if !categories.is_empty() { calendar.remove_properties("VEVENT", &["CATEGORIES"]); let kept: Vec = categories .iter() .flat_map(|value| value.split(',')) .map(str::trim) .filter(|category| !category.eq_ignore_ascii_case(hint) && !category.is_empty()) .map(str::to_string) .collect(); if !kept.is_empty() { calendar.add_property("VEVENT", "CATEGORIES", &kept.join(",")); } } let Some(description) = calendar .properties("VEVENT", "DESCRIPTION") .next() .map(|property| property.value.to_string()) else { return; }; let marker = format!("{ROUTE_MARKER}{hint}"); let kept: Vec<&str> = description .split("\\n") .filter(|line| line.trim() != marker) .collect(); if kept.iter().all(|line| line.trim().is_empty()) { calendar.remove_properties("VEVENT", &["DESCRIPTION"]); } else { calendar.set_property("VEVENT", "DESCRIPTION", &kept.join("\\n")); } } /// 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. pub(crate) 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 (ACCEPTED)")); assert!(inert.contains("Them (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"); } }