CalCalist/src/mirror.rs
randogoth 47ad8b4c47 Make an aggregate's own events a first-class mode
An aggregate with no default_sink already left events created in it alone, but
treated doing so as a failure: it reported a skip per event per cycle saying no
sink was configured, as though something had gone wrong. Nothing had. An
aggregate is also a calendar, and holding events of its own is a legitimate way
to use one.

Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local`
joins the routing markers, so the mode also works per-event where a
default_sink is configured — which was not previously expressible. Unlike every
other marker it is deliberately not stripped: the others have done their job
once the event reaches its source, whereas this one never leaves, so it has to
stay legible for the next cycle to reach the same decision. `local` is
therefore a reserved endpoint id, and configuring one is refused.

This also fixes a real defect. `retarget` rebuilds the new target from the
recorded links, which cover derived events only, so an event belonging to the
aggregate itself did not follow the move — it stayed on the calendar being left
behind while everything around it moved on, quietly. It is now carried across,
since there is nothing to re-derive it from, and the new target's scheduling
rule is applied on the way: this is a write to an aggregate like any other, and
a guest list carried live onto a server that schedules would mail everyone on
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:34:15 +03:00

505 lines
19 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"];
/// 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<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);
}
/// 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<String> {
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::<Vec<_>>()
})
.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<String> {
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<String> = calendar
.properties("VEVENT", "CATEGORIES")
.map(|property| property.value.to_string())
.collect();
if !categories.is_empty() {
calendar.remove_properties("VEVENT", &["CATEGORIES"]);
let kept: Vec<String> = 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 <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");
}
}