Sync WebCal and CalDAV directly; add a skip_past cutoff
WebCal is calcalist's own (src/webcal.rs), with Calendar::split_by_uid to turn one feed document into one file per UID. Shared crate::http and crate::secret came out of google/ so every leg uses them. CalDAV follows, behind a per-endpoint 'native' flag so it can run beside pimsync one collection at a time: src/caldav/client.rs speaks PROPFIND, sync-collection, calendar-multiget and conditional PUT/DELETE, and src/caldav/mod.rs is the pull/push pair. Radicale answers an initial sync-collection with no members, so a cursor is only stored alongside a complete read. doctor reports what a server advertises. skip_past (src/horizon.rs) takes a date, not a flag: a rolling 'before now' would retire events from the aggregate as they aged, deleting yesterday's events on every cycle. Pinned to a date, what is mirrored stays mirrored. On the real configuration it leaves 2600 of 3282 events behind. The filter sits above the prior-links lookup in mirror_source, which is what makes it safe: from there DeleteSource is unreachable, so excluding an event can never be read as the user having deleted its mirror. pimsync: CWD pinned so vstorage's relative unlink cannot reach the user's files; the dry-run workspace is removed by Drop rather than by a line every '?' stepped over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d03748472a
commit
622a71dcd6
28 changed files with 2717 additions and 188 deletions
253
tests/caldav.rs
253
tests/caldav.rs
|
|
@ -23,6 +23,12 @@ const MEETING: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
|||
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Soon\r\n\
|
||||
END:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
/// An event added to a source after a cycle has already stored a cursor.
|
||||
const LATER: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:later@work\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART:20260915T090000Z\r\nDTEND:20260915T100000Z\r\n\
|
||||
SUMMARY:Retrospective\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
/// An event written straight into the aggregate, belonging to no source.
|
||||
const HAND_WRITTEN: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:hand-written@phone\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
|
|
@ -497,3 +503,250 @@ fn a_second_cycle_is_refused_while_one_is_running() {
|
|||
"the lock should be free again"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same cycle, driven by calcalist's own CalDAV client instead of pimsync.
|
||||
///
|
||||
/// This is the comparison the `native` flag exists for: the assertions are the
|
||||
/// pimsync test's, so a divergence between the two implementations shows up as
|
||||
/// a failure here rather than as a surprise on someone's calendar.
|
||||
fn native_fixture() -> Option<Fixture> {
|
||||
let missing = support::missing_binaries();
|
||||
if !missing.is_empty() {
|
||||
eprintln!(
|
||||
"skipped: {} not on PATH; run under devbox",
|
||||
missing.join(", ")
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let root = tempfile::tempdir().expect("temp");
|
||||
let server = Radicale::start(root.path());
|
||||
server.create_calendar("work");
|
||||
server.create_calendar("unified");
|
||||
server.put_event("work", "meeting.ics", MEETING);
|
||||
let feed = Feed::start(HOLIDAY_FEED.to_string());
|
||||
|
||||
let config = format!(
|
||||
r#"
|
||||
version = 1
|
||||
|
||||
[[endpoint]]
|
||||
id = "work"
|
||||
type = "caldav"
|
||||
native = true
|
||||
url = "{work}"
|
||||
username = "{user}"
|
||||
secret_command = "printf password"
|
||||
|
||||
[[endpoint]]
|
||||
id = "published"
|
||||
type = "caldav"
|
||||
native = true
|
||||
url = "{unified}"
|
||||
username = "{user}"
|
||||
secret_command = "printf password"
|
||||
|
||||
[[endpoint]]
|
||||
id = "holidays"
|
||||
type = "webcal"
|
||||
url = "{feed}"
|
||||
|
||||
[[aggregate]]
|
||||
id = "everything"
|
||||
target = "published"
|
||||
sources = ["work", "holidays"]
|
||||
default_sink = "work"
|
||||
"#,
|
||||
work = server.url("work"),
|
||||
unified = server.url("unified"),
|
||||
user = support::USER,
|
||||
feed = feed.url(),
|
||||
);
|
||||
|
||||
let calcalist = Calcalist::new(root.path(), &config);
|
||||
Some(Fixture {
|
||||
_root: root,
|
||||
server,
|
||||
_feed: feed,
|
||||
calcalist,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_cycle_converges_and_the_next_one_does_nothing() {
|
||||
let Some(fixture) = native_fixture() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let first = fixture.calcalist.run(&["sync"]);
|
||||
assert!(
|
||||
first.succeeded(),
|
||||
"first native sync failed\n{}\n{}",
|
||||
first.stdout,
|
||||
first.stderr
|
||||
);
|
||||
|
||||
let published = fixture.server.stored("unified");
|
||||
assert_eq!(
|
||||
published.len(),
|
||||
2,
|
||||
"both sources should arrive: {published:?}"
|
||||
);
|
||||
let all = published.join("\n");
|
||||
assert!(all.contains("Planning"), "{all}");
|
||||
assert!(all.contains("New Year"), "{all}");
|
||||
assert!(all.contains("X-CALCALIST-SOURCE:work"), "{all}");
|
||||
assert!(all.contains("X-CALCALIST-SOURCE:holidays"), "{all}");
|
||||
|
||||
let second = fixture.calcalist.run(&["sync"]);
|
||||
assert!(
|
||||
second.succeeded(),
|
||||
"second native sync failed\n{}\n{}",
|
||||
second.stdout,
|
||||
second.stderr
|
||||
);
|
||||
assert!(
|
||||
second.stdout.contains("everything: 0 mirrored"),
|
||||
"the second cycle should be a no-op:\n{}",
|
||||
second.stdout
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.server.stored("unified"),
|
||||
published,
|
||||
"the second cycle rewrote the target"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor is only worth storing if the cycle after it still sees everything.
|
||||
/// Radicale answers the first `sync-collection` with no members at all, so a
|
||||
/// client that trusted it would mirror an empty calendar and call it converged.
|
||||
#[test]
|
||||
fn a_native_pull_sees_an_event_added_behind_its_back() {
|
||||
let Some(fixture) = native_fixture() else {
|
||||
return;
|
||||
};
|
||||
|
||||
assert!(fixture.calcalist.run(&["sync"]).succeeded(), "first sync");
|
||||
|
||||
fixture.server.put_event("work", "later.ics", LATER);
|
||||
let second = fixture.calcalist.run(&["sync"]);
|
||||
assert!(
|
||||
second.succeeded(),
|
||||
"second native sync failed\n{}\n{}",
|
||||
second.stdout,
|
||||
second.stderr
|
||||
);
|
||||
|
||||
let published = fixture.server.stored("unified").join("\n");
|
||||
assert!(
|
||||
published.contains("Retrospective"),
|
||||
"an event added after the cursor was stored should still arrive:\n{published}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `skip_past` on an aggregate, end to end.
|
||||
///
|
||||
/// The source holds one finished event and one still to come, and the feed holds
|
||||
/// a New Year that is long gone. Only the future event should be published.
|
||||
#[test]
|
||||
fn skip_past_publishes_only_what_is_still_to_come() {
|
||||
let missing = support::missing_binaries();
|
||||
if !missing.is_empty() {
|
||||
eprintln!(
|
||||
"skipped: {} not on PATH; run under devbox",
|
||||
missing.join(", ")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = tempfile::tempdir().expect("temp");
|
||||
let server = Radicale::start(root.path());
|
||||
server.create_calendar("work");
|
||||
server.create_calendar("unified");
|
||||
server.put_event("work", "meeting.ics", MEETING);
|
||||
server.put_event("work", "later.ics", LATER);
|
||||
let feed = Feed::start(HOLIDAY_FEED.to_string());
|
||||
|
||||
let config = format!(
|
||||
r#"
|
||||
version = 1
|
||||
|
||||
[[endpoint]]
|
||||
id = "work"
|
||||
type = "caldav"
|
||||
native = true
|
||||
url = "{work}"
|
||||
username = "{user}"
|
||||
secret_command = "printf password"
|
||||
|
||||
[[endpoint]]
|
||||
id = "published"
|
||||
type = "caldav"
|
||||
native = true
|
||||
url = "{unified}"
|
||||
username = "{user}"
|
||||
secret_command = "printf password"
|
||||
|
||||
[[endpoint]]
|
||||
id = "holidays"
|
||||
type = "webcal"
|
||||
url = "{feed}"
|
||||
|
||||
[[aggregate]]
|
||||
id = "everything"
|
||||
target = "published"
|
||||
sources = ["work", "holidays"]
|
||||
default_sink = "work"
|
||||
skip_past = "2026-09-13"
|
||||
"#,
|
||||
work = server.url("work"),
|
||||
unified = server.url("unified"),
|
||||
user = support::USER,
|
||||
feed = feed.url(),
|
||||
);
|
||||
let calcalist = Calcalist::new(root.path(), &config);
|
||||
|
||||
let first = calcalist.run(&["sync"]);
|
||||
assert!(
|
||||
first.succeeded(),
|
||||
"sync failed\n{}\n{}",
|
||||
first.stdout,
|
||||
first.stderr
|
||||
);
|
||||
|
||||
let published = server.stored("unified");
|
||||
let all = published.join("\n");
|
||||
assert!(
|
||||
all.contains("Retrospective"),
|
||||
"the future event should be published:\n{all}"
|
||||
);
|
||||
assert!(
|
||||
!all.contains("Planning"),
|
||||
"the finished meeting should have been left behind:\n{all}"
|
||||
);
|
||||
assert!(
|
||||
!all.contains("New Year"),
|
||||
"the finished feed event should have been left behind:\n{all}"
|
||||
);
|
||||
assert_eq!(
|
||||
published.len(),
|
||||
1,
|
||||
"only one event should arrive: {published:?}"
|
||||
);
|
||||
|
||||
// The originals are untouched: excluding history must never reach back into
|
||||
// a source calendar.
|
||||
assert_eq!(
|
||||
server.stored("work").len(),
|
||||
2,
|
||||
"both source events must survive"
|
||||
);
|
||||
|
||||
let second = calcalist.run(&["sync"]);
|
||||
assert!(second.succeeded(), "second sync failed\n{}", second.stderr);
|
||||
assert!(
|
||||
second.stdout.contains("everything: 0 mirrored"),
|
||||
"the second cycle should be a no-op:\n{}",
|
||||
second.stdout
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue