Let an event choose which source it is filed under

An event created in an aggregate went to the configured default_sink and nowhere
else, so with several writable sources there was no way to say which calendar a
new event belonged in.

A line reading @endpoint-id in the description now picks the source, and a
matching CATEGORIES value does too. The description rather than the title because
every calendar client exposes a notes field and editing it does not disfigure the
event's name; CATEGORIES as well because that is the field iCalendar intends,
even though many mobile clients hide it. The marker is stripped before the event
reaches the calendar, being calcalist's bookkeeping rather than content.

A marker naming something that is not a writable source of that aggregate is
refused and reported, not redirected to the default: a typo should not quietly
file an event in the wrong calendar. A bare address in prose is not a marker
either, since a marker must be a line of its own.

Also covers the shapes beyond many-into-one: a source feeding several aggregates,
several aggregates sharing one target, a cycle between two aggregates, a delete
cascading across aggregates, and competing edits arriving through two aggregates
at once — the last being caught by the existing conflict detection rather than
silently overwriting.

Error display no longer repeats itself; thiserror already prints the cause chain.

Verified live against real accounts: two Google calendars aggregating into a
Posteo calendar, an edit in the aggregate reaching the originating Google
calendar, an event routed to a chosen source by its description marker, and a
deletion propagating from the aggregate through to Google.

129 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 13:29:02 +03:00
parent bc64f19211
commit 0c0558c24c
6 changed files with 506 additions and 12 deletions

View file

@ -106,6 +106,88 @@ fn append_guests_to_description(calendar: &mut Calendar, guests: &[String]) {
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