diff --git a/README.md b/README.md index 60deca9..f0034bf 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,9 @@ reported in one pass: - `target` must not also be one of its own `sources`. - `sources` must be non-empty, and each must exist and be named once. - `default_sink`, if given, must be one of the `sources` and must be writable. + Leaving it out is a deliberate mode, not an omission — see + [Events of the aggregate's own](#events-of-the-aggregates-own). +- No endpoint may be called `local`, which is reserved for the routing marker. ### A complete example @@ -256,6 +259,7 @@ aggregate `unified` new events go to `gcal` unless told otherwise to choose, put one of these on a line of its own in the event's notes: @gcal + @local (keep the event here, in this calendar only) ``` Read-only feeds are left out, since they cannot take an event. A marker naming @@ -263,6 +267,31 @@ anything else is refused and reported — the event stays where it is rather tha being quietly filed under the default, which would put it somewhere you did not ask for. +### Events of the aggregate's own + +An aggregate is also a calendar, and it can hold events that belong to nothing +else. There are two ways to get one: + +- **`@local`** on an event, exactly like any other marker, keeps it where it was + written even though a `default_sink` would otherwise have taken it. Unlike + every other marker it is *not* removed from the event: the others have done + their job once the event reaches its source, whereas this one has to stay + legible so every later cycle reaches the same decision. +- **Leave `default_sink` out of the aggregate entirely.** Then nothing is + configured to file a new event under, so every untagged event stays put and + only `@`-tagged ones are sent anywhere. Routing becomes opt-in rather than + opt-out. + +`local` is a reserved endpoint id for this reason, and configuring an endpoint +with that name is refused. + +One thing to be aware of: every other event in an aggregate is derived from a +source, so the aggregate is disposable — lose the calendar and it rebuilds +itself on the next sync. **An event of the aggregate's own exists in exactly one +place.** Nothing else holds a copy, so it is only as safe as that calendar is. +`aggregate retarget` carries these events to the new target along with +everything else, since there is nothing to re-derive them from. + A **category** matching an endpoint id works too, that being the field iCalendar intends for this. Be aware that any category on a newly created event is read as a routing instruction, so an event carrying an unrelated category diff --git a/src/config.rs b/src/config.rs index 4e606d4..0617fc3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -207,10 +207,20 @@ pub enum Problem { NoUrlSource { endpoint: String }, #[error("endpoint `{endpoint}` has both `url` and `url_command`; use one or the other")] AmbiguousUrlSource { endpoint: String }, + #[error( + "endpoint `{endpoint}` uses a reserved id: `@{endpoint}` means \"keep this event in the aggregate\", so an endpoint of that name could never be routed to" + )] + ReservedEndpointId { endpoint: String }, } -/// A webcal feed must name exactly one source for its URL. +/// A webcal feed must name exactly one source for its URL, and no endpoint may +/// take a name the routing markers have already spoken for. fn check_endpoint(endpoint: &Endpoint, problems: &mut Vec) { + if crate::mirror::is_local_sink(&endpoint.id) { + problems.push(Problem::ReservedEndpointId { + endpoint: endpoint.id.clone(), + }); + } let EndpointKind::Webcal { url, url_command } = &endpoint.kind else { return; }; @@ -609,4 +619,25 @@ sources = ["work", "work"] endpoint: "work".into(), })); } + + /// `@local` already means "keep this event here", so an endpoint of that + /// name could never be routed to. + #[test] + fn an_endpoint_may_not_take_the_reserved_name() { + let problems = problems( + r#" +[[endpoint]] +id = "local" +type = "caldav" +url = "https://caldav.example.com/other/" +username = "me@example.com" +"#, + ); + assert!( + problems + .iter() + .any(|problem| matches!(problem, Problem::ReservedEndpointId { .. })), + "{problems:?}" + ); + } } diff --git a/src/main.rs b/src/main.rs index bb23631..d30f7b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,6 +195,12 @@ fn run_retarget(cli: &Cli, id: &str, to: &str, purge_old: bool) -> ExitCode { outcome.routed_first, outcome.purged, ); + if outcome.carried > 0 { + println!( + " {} event(s) belonging to the aggregate itself were moved across", + outcome.carried + ); + } if outcome.purged == 0 && !purge_old { println!( " the previous target still holds this aggregate's events; re-run with --purge-old to remove them" @@ -269,6 +275,12 @@ fn print_report(report: &Report) { aggregate.deleted_from_aggregate, aggregate.deleted_from_sources, ); + if aggregate.kept_local > 0 { + println!( + " {} event(s) kept local: they belong to the aggregate itself, not to a source", + aggregate.kept_local + ); + } for conflict in &aggregate.conflicts { println!( " conflict: {} changed in both places; kept the version from `{}`", @@ -286,9 +298,6 @@ fn print_report(report: &Report) { fn describe(skipped: &reconcile::Skipped) -> String { match skipped { - reconcile::Skipped::NoSink { aggregate_uid } => format!( - "`{aggregate_uid}` was created in the aggregate, but no default_sink is set to route it to" - ), reconcile::Skipped::ReadOnlySource { aggregate_uid, source_id, diff --git a/src/mirror.rs b/src/mirror.rs index f846344..1bf3f61 100644 --- a/src/mirror.rs +++ b/src/mirror.rs @@ -30,6 +30,15 @@ const OWN_PROPERTIES: &[&str] = &[SOURCE_PROPERTY, ORIGIN_UID_PROPERTY, ATTENDEE /// 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, @@ -52,6 +61,24 @@ pub fn to_aggregate( 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 diff --git a/src/reconcile.rs b/src/reconcile.rs index 4eb4635..0d339ef 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -83,9 +83,6 @@ pub struct Conflict { /// Something that could not be done, and why. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Skipped { - /// A user created an event in the aggregate, but no sink is configured to - /// route it to, and guessing one would put it in the wrong calendar. - NoSink { aggregate_uid: String }, /// An edit was made to a mirror of a read-only feed, which cannot accept it. ReadOnlySource { aggregate_uid: String, @@ -109,6 +106,9 @@ pub struct Outcome { pub links: BTreeMap, pub conflicts: Vec, pub skipped: Vec, + /// Events left in the aggregate rather than filed under a source. Not a + /// failure: an aggregate is also a calendar, and these are its own events. + pub kept_local: Vec, } #[derive(Debug, Error, PartialEq)] @@ -140,6 +140,7 @@ pub fn reconcile( links: BTreeMap::new(), conflicts: Vec::new(), skipped: Vec::new(), + kept_local: Vec::new(), }; let mut live: HashSet = HashSet::new(); let mut deletions = 0usize; @@ -360,6 +361,14 @@ fn route_new_events( // An event may name the source it belongs in; otherwise the aggregate's // configured sink takes it. let requested = mirror::routing_hint(&item.calendar); + // `@local` keeps the event where it was written. Unlike every other + // marker it is deliberately not stripped: the others have done their + // job once the event reaches its source, while this one has to survive + // to make the same decision on every later cycle. + if requested.as_deref().is_some_and(mirror::is_local_sink) { + outcome.kept_local.push(uid.clone()); + continue; + } let wanted = requested.as_deref().or(policy.default_sink); // Case-insensitively: the name is typed into a calendar app by hand, and // capitalisation is not worth failing over. @@ -372,16 +381,18 @@ fn route_new_events( .filter(|sink| sink.writable); let Some(sink) = sink else { - outcome.skipped.push(match requested { - Some(requested) => Skipped::UnknownSink { + match requested { + Some(requested) => outcome.skipped.push(Skipped::UnknownSink { aggregate_uid: uid.clone(), requested, available: writable_sinks(sources), - }, - None => Skipped::NoSink { - aggregate_uid: uid.clone(), - }, - }); + }), + // Nothing says where this event should go, so it stays where it + // was written. With no default_sink configured that is the + // mode, not an omission — the aggregate is a calendar the user + // can also write in directly. + None => outcome.kept_local.push(uid.clone()), + } continue; }; @@ -879,7 +890,9 @@ mod tests { ); } - /// Guessing a sink would put the event in the wrong calendar, so refuse. + /// With nowhere configured to file it, the event stays where it was + /// written. Guessing a sink would put it in the wrong calendar, and this is + /// how an aggregate holds events of its own. #[test] fn without_a_sink_a_user_created_event_is_left_alone() { let sources = map(vec![]); @@ -887,12 +900,30 @@ mod tests { let outcome = reconcile(policy(), &[view(&sources, true)], &aggregate, None).expect("ok"); assert!(outcome.actions.is_empty()); - assert_eq!( - outcome.skipped, - vec![Skipped::NoSink { - aggregate_uid: "hand-written@phone".into() - }] - ); + assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped); + assert_eq!(outcome.kept_local, vec!["hand-written@phone".to_string()]); + } + + /// `@local` opts one event out even where a default sink would take it. + #[test] + fn the_local_marker_keeps_an_event_out_of_every_source() { + let sources = map(vec![]); + let mut tagged = item("hand-written@phone", "Dentist"); + tagged + .calendar + .set_property("VEVENT", "DESCRIPTION", "Bring the referral\\n\\n@local"); + tagged.hash = tagged.calendar.content_hash(); + let aggregate = map(vec![tagged]); + + let policy = Policy { + default_sink: Some(SRC), + ..policy() + }; + let outcome = reconcile(policy, &[view(&sources, true)], &aggregate, None).expect("ok"); + + assert!(outcome.actions.is_empty(), "{:?}", outcome.actions); + assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped); + assert_eq!(outcome.kept_local, vec!["hand-written@phone".to_string()]); } /// Our own mirror appearing inside a source must not be mirrored again. diff --git a/src/retarget.rs b/src/retarget.rs index 3b1f54c..0cbd347 100644 --- a/src/retarget.rs +++ b/src/retarget.rs @@ -55,6 +55,8 @@ pub struct Outcome { /// Events flushed to a sink before the move, which existed nowhere else. pub routed_first: usize, pub materialised: usize, + /// Events belonging to the aggregate itself, moved rather than re-derived. + pub carried: usize, pub purged: usize, } @@ -117,7 +119,14 @@ pub fn retarget( false, )?; - let materialised = materialise(config, aggregate, new_target, state_dir, &mut state)?; + let (materialised, carried) = materialise( + config, + aggregate, + &old_target, + new_target, + state_dir, + &mut state, + )?; let purged = if purge_old { purge(aggregate_id, &old_target, state_dir, &state)? } else { @@ -138,6 +147,7 @@ pub fn retarget( to: new_target.id.clone(), routed_first: settled.written_back, materialised, + carried, purged, }) } @@ -150,10 +160,11 @@ pub fn retarget( fn materialise( config: &Config, aggregate: &crate::config::Aggregate, + old_target: &Endpoint, new_target: &Endpoint, state_dir: &Path, state: &mut State, -) -> Result { +) -> Result<(usize, usize), RetargetError> { let new_dir = vdir_path(state_dir, &new_target.id); let existing = vdir::read(&new_dir)?; let suppression = new_target.kind.scheduling_suppression(); @@ -188,10 +199,30 @@ fn materialise( } written += 1; } + + // Events created in the aggregate and never filed under a source exist only + // on the old target, so there is nothing to re-derive them from: they are + // moved rather than rebuilt. Without this they would sit on the calendar + // being left behind while everything around them moved on. + // + // The new target's scheduling rule still applies. 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. + let mut carried = 0; + for (uid, item) in vdir::read(&vdir_path(state_dir, &old_target.id))? { + if provenance::is_derived(&uid) { + continue; + } + let inert = mirror::make_inert(&item.calendar, new_target.kind.owner(), suppression); + let replaces = existing.get(&uid).map(|held| held.path.clone()); + vdir::write(&new_dir, &uid, &inert, replaces.as_deref())?; + carried += 1; + } + state .aggregate_mut(&aggregate.id, target_of(new_target)) .links = updated; - Ok(written) + Ok((written, carried)) } /// Removes this aggregate's mirrors from the calendar it has left. @@ -280,6 +311,53 @@ default_sink = "src" ) } + /// An event belonging to the aggregate itself has to follow the move. + /// + /// There is nothing to re-derive it from, so before this it stayed on the + /// calendar being left behind while every mirrored event moved on — quietly, + /// which is the worst way for a calendar to lose an appointment. + #[test] + fn an_event_belonging_to_the_aggregate_is_carried_to_the_new_target() { + // No default_sink: events written into the aggregate stay there. + let config: Config = toml::from_str(&CONFIG.replace(r#"default_sink = "src""#, "")) + .expect("config should parse"); + let dir = seed(&config); + + let old = vdir_path(dir.path(), "agg1"); + fs::write( + old.join("dentist.ics"), + event("hand-written@phone", "Dentist"), + ) + .expect("write"); + settle(&config, dir.path()); + + let outcome = retarget(&config, dir.path(), "unified", "agg2", false).expect("retarget"); + + assert_eq!(outcome.carried, 1, "{outcome:?}"); + let moved = vdir::read(&vdir_path(dir.path(), "agg2")).expect("read"); + assert!( + moved.contains_key("hand-written@phone"), + "the aggregate's own event did not follow: {:?}", + moved.keys().collect::>() + ); + assert!( + moved["hand-written@phone"] + .calendar + .to_ics() + .contains("Dentist"), + "it arrived without its content" + ); + // And it is still not a mirror of anything, so nothing was invented for it. + let state = State::load(&dir.path().join(crate::state::FILE_NAME)).expect("state"); + assert!( + !state + .aggregate("unified") + .expect("aggregate") + .links + .contains_key("hand-written@phone") + ); + } + /// Reconciles one aggregate and records the result, without running a full /// cycle. A full cycle would contact Google for real, which these tests have /// no business doing — they are about what happens to local files. diff --git a/src/status.rs b/src/status.rs index e13d427..6932cb3 100644 --- a/src/status.rs +++ b/src/status.rs @@ -69,7 +69,7 @@ fn describe_routing(out: &mut String, config: &Config, aggregate: &Aggregate) { None => { let _ = writeln!( out, - " new events are left alone: no default_sink is configured" + " new events stay here: no default_sink is configured, so the aggregate keeps its own" ); } } @@ -84,6 +84,11 @@ fn describe_routing(out: &mut String, config: &Config, aggregate: &Aggregate) { for sink in sinks { let _ = writeln!(out, " @{sink}"); } + let _ = writeln!( + out, + " @{} (keep the event here, in this calendar only)", + crate::mirror::LOCAL_SINK + ); } #[cfg(test)] @@ -147,12 +152,18 @@ default_sink = "gcal" assert!(report().contains("new events go to `gcal`")); } + /// The escape hatch has to be as findable as the destinations. + #[test] + fn it_names_the_marker_that_keeps_an_event_local() { + assert!(report().contains("@local"), "{}", report()); + } + #[test] fn it_says_when_nothing_would_be_routed() { let sinkless: Config = toml::from_str(&CONFIG.replace(r#"default_sink = "gcal""#, "")).expect("config"); let dir = tempfile::tempdir().expect("temp dir"); let report = render(&sinkless, dir.path()).expect("render"); - assert!(report.contains("no default_sink is configured"), "{report}"); + assert!(report.contains("the aggregate keeps its own"), "{report}"); } } diff --git a/src/sync.rs b/src/sync.rs index 1494c70..fe491d4 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -123,6 +123,7 @@ pub struct AggregateReport { pub deleted_from_sources: usize, pub conflicts: Vec, pub skipped: Vec, + pub kept_local: usize, } /// Runs the local half of a cycle: reconcile every aggregate and apply the result. @@ -564,6 +565,7 @@ pub(crate) fn sync_aggregate( deleted_from_sources: 0, conflicts: outcome.conflicts, skipped: outcome.skipped, + kept_local: outcome.kept_local.len(), }; for action in &outcome.actions { diff --git a/tests/caldav.rs b/tests/caldav.rs index 49ecc63..88eac3c 100644 --- a/tests/caldav.rs +++ b/tests/caldav.rs @@ -29,6 +29,12 @@ const HAND_WRITTEN: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//E DTSTART:20260911T140000Z\r\nDTEND:20260911T150000Z\r\nSUMMARY:Dentist\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; +/// The same event, asking to be left where it was written. +const KEPT_LOCAL: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\ + BEGIN:VEVENT\r\nUID:kept@phone\r\nDTSTAMP:20260101T000000Z\r\n\ + DTSTART:20260912T140000Z\r\nDTEND:20260912T150000Z\r\nSUMMARY:Haircut\r\n\ + DESCRIPTION:Around the corner\\n\\n@local\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + const HOLIDAY_FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//feed//EN\r\n\ BEGIN:VEVENT\r\nUID:newyear@feed\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\n\ @@ -329,3 +335,51 @@ fn an_event_created_in_a_sinkless_aggregate_stays_where_it_is() { ); } } + +/// `@local` opts one event out of a default sink that would otherwise take it. +/// +/// The marker has to survive, unlike every other one: it is stripped markers +/// that have done their job on arrival, whereas this event never leaves, so the +/// next cycle has to be able to reach the same decision. +#[test] +fn the_local_marker_keeps_an_event_out_of_a_configured_sink() { + let Some(fixture) = fixture() else { return }; + + assert!(fixture.calcalist.run(&["sync"]).succeeded()); + fixture + .server + .put_event("unified", "haircut.ics", KEPT_LOCAL); + + for cycle in 1..=3 { + let run = fixture.calcalist.run(&["sync"]); + assert!( + run.succeeded(), + "cycle {cycle} failed\n{}\n{}", + run.stdout, + run.stderr + ); + assert!( + run.stdout.contains("1 event(s) kept local"), + "cycle {cycle} should report it as kept, not skipped:\n{}", + run.stdout + ); + + let kept = fixture + .server + .stored("unified") + .into_iter() + .find(|item| item.contains("Haircut")) + .expect("the event should still be in the aggregate"); + // Stripping it would let the default sink claim the event next cycle. + assert!(kept.contains("@local"), "cycle {cycle}: {kept}"); + + assert!( + !fixture + .server + .stored("work") + .iter() + .any(|item| item.contains("Haircut")), + "cycle {cycle}: it reached the default sink anyway" + ); + } +}