CalCalist/src/mirror.rs

397 lines
15 KiB
Rust
Raw Normal View History

Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
//! 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");
}
}