Close the remaining M1 gaps
Eight items were still open at the end of M1: two integration tests that had only been run by hand, and six known gaps. Recurrence overrides now reach Google. Google addresses an exception through the series rather than as an event of its own, so the master is sent first and each override is then matched to its instance by original start time and patched. Matching needs the two sides' spellings reduced to one key: iCalendar writes a zoned local time, Google an absolute offset. An override matching no occurrence is counted rather than forced — that means a stale RECURRENCE-ID left behind by an edited RRULE, and inventing an event for it would put something in the calendar the series does not contain. Reading one component apart from another needed a view `properties` cannot give: it flattens every VEVENT together, which is right for the UID a series shares and wrong for an override, whose SUMMARY and the master's are then indistinguishable. `Calendar::events` splits them. A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. This changes the content hash of every zoned recurring event, so the first cycle after this re-pushes them. A Google authorisation is filed under the account it was granted for rather than the endpoint that asked for it, so two endpoints on one account no longer need a login each. The account is read from the primary calendar's id, which needs no scope beyond the calendar one already granted. Authorisations written by the previous scheme are still honoured, and move across at the next login. An unreachable Google endpoint no longer ends the cycle — one lapsed token used to stop the CalDAV side too. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a partial cycle cannot pass for success. The CalDAV leg cannot be narrowed the same way: pimsync is one process covering every pair, so a failure does not say which pair it belongs to. `--dry-run` now pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now rather than against whatever the last real cycle left behind. `calcalist prune` reports local mirrors of endpoints the configuration no longer names, and removes them under --force. The integration tests run against a real Radicale server and a real iCal feed: convergence and idempotence, the dry run, prune, and the scheduling rule asserted on the bytes that actually reached the server. The plan asked for an SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a quiet SMTP port would have proved nothing about the transform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
06e661b0c4
commit
623505b9c8
14 changed files with 2008 additions and 144 deletions
|
|
@ -106,16 +106,31 @@ pub fn config_path(state_dir: &Path) -> PathBuf {
|
|||
state_dir.join("pimsync.conf")
|
||||
}
|
||||
|
||||
fn status_path(state_dir: &Path) -> PathBuf {
|
||||
pub(crate) fn status_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join("pimsync-status")
|
||||
}
|
||||
|
||||
/// Which way a generated configuration lets items travel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
/// The ordinary cycle: remote and local converge on each other.
|
||||
Both,
|
||||
/// Remote to local only, for `--dry-run`. pimsync writes nothing to any
|
||||
/// server in this mode — but it does delete local items the remote has
|
||||
/// never seen, so it is only ever pointed at a throwaway copy of the vdirs.
|
||||
PullOnly,
|
||||
}
|
||||
|
||||
/// Builds a pimsync configuration covering every CalDAV and WebCal endpoint.
|
||||
///
|
||||
/// Google endpoints are absent by design: pimsync has no REST storage, and its
|
||||
/// CalDAV storage speaks only HTTP Basic auth, which Google's endpoint has
|
||||
/// rejected since March 2025. calcalist syncs those itself.
|
||||
pub fn generate(config: &Config, state_dir: &Path) -> Result<String, PimsyncError> {
|
||||
pub fn generate_for(
|
||||
config: &Config,
|
||||
state_dir: &Path,
|
||||
direction: Direction,
|
||||
) -> Result<String, PimsyncError> {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Generated by calcalist. Edits here are overwritten on every sync.\n\n");
|
||||
out.push_str(&format!(
|
||||
|
|
@ -129,7 +144,7 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result<String, PimsyncErro
|
|||
|
||||
for endpoint in &config.endpoints {
|
||||
match &endpoint.kind {
|
||||
EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint)?),
|
||||
EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint, direction)?),
|
||||
EndpointKind::Webcal { url, url_command } => {
|
||||
out.push_str(&webcal_pair(
|
||||
endpoint,
|
||||
|
|
@ -144,7 +159,7 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result<String, PimsyncErro
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn caldav_pair(endpoint: &Endpoint) -> Result<String, PimsyncError> {
|
||||
fn caldav_pair(endpoint: &Endpoint, direction: Direction) -> Result<String, PimsyncError> {
|
||||
let EndpointKind::Caldav {
|
||||
url,
|
||||
username,
|
||||
|
|
@ -175,10 +190,15 @@ fn caldav_pair(endpoint: &Endpoint) -> Result<String, PimsyncError> {
|
|||
// The remote is storage_a, so a concurrent server-side change wins over a
|
||||
// local one calcalist has not yet reconciled. Nothing is lost: the next
|
||||
// cycle sees the server's version and decides properly.
|
||||
let resolution = match direction {
|
||||
// `one_way` never produces a conflict, so there is nothing to resolve.
|
||||
Direction::PullOnly => "\tone_way\n",
|
||||
Direction::Both => "\tconflict_resolution keep a\n",
|
||||
};
|
||||
block.push_str(&format!(
|
||||
"\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\
|
||||
\tcollection {{\n\t\talias {id}\n\t\thref_a {}\n\t\tid_b {id}\n\t}}\n\
|
||||
\tconflict_resolution keep a\n\ton_empty skip\n\ton_delete skip\n}}\n",
|
||||
{resolution}\ton_empty skip\n\ton_delete skip\n}}\n",
|
||||
quote(&href)
|
||||
));
|
||||
Ok(block)
|
||||
|
|
@ -235,8 +255,16 @@ fn quote(value: &str) -> String {
|
|||
|
||||
/// Writes the generated configuration, returning its path.
|
||||
pub fn write_config(config: &Config, state_dir: &Path) -> Result<PathBuf, PimsyncError> {
|
||||
write_config_for(config, state_dir, Direction::Both)
|
||||
}
|
||||
|
||||
pub fn write_config_for(
|
||||
config: &Config,
|
||||
state_dir: &Path,
|
||||
direction: Direction,
|
||||
) -> Result<PathBuf, PimsyncError> {
|
||||
let path = config_path(state_dir);
|
||||
let text = generate(config, state_dir)?;
|
||||
let text = generate_for(config, state_dir, direction)?;
|
||||
fs::create_dir_all(status_path(state_dir)).map_err(|source| PimsyncError::Write {
|
||||
path: status_path(state_dir),
|
||||
source,
|
||||
|
|
@ -327,7 +355,8 @@ url = "https://example.org/holidays.ics"
|
|||
|
||||
fn generated() -> String {
|
||||
let config: Config = toml::from_str(CONFIG).expect("config should parse");
|
||||
generate(&config, Path::new("/var/state/calcalist")).expect("generation should succeed")
|
||||
generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both)
|
||||
.expect("generation should succeed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -404,7 +433,8 @@ url_command = "secret-tool lookup service calcalist account gcal-ics"
|
|||
"#,
|
||||
)
|
||||
.expect("config should parse");
|
||||
let text = generate(&config, Path::new("/var/state/calcalist")).expect("generate");
|
||||
let text = generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both)
|
||||
.expect("generate");
|
||||
|
||||
assert!(text.contains("url {"));
|
||||
assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue