feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578)

Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen,
Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete
damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn
Minuten, auf Akku und Datenvolumen des Telefons.

Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die
bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den
Stand, bei dem es zuletzt angefasst wurde (Migration 24).

Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe
ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden
war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein
Token darunter bekommt 403 <valid-sync-token/> statt einer Antwort mit
fehlenden Loeschungen.

go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und
weder sync-token noch supported-report-set. Beide gehen denselben Weg wie
getctag und calendar-color. Ohne supported-report-set probiert DAVx5
sync-collection gar nicht erst aus.

Zwei Fehler noch vor dem Ausliefern gefunden:

* Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung --
  xml.EscapeText machte daraus &#34;. Gueltig, aber genug Clients
  vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung.
* pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als
  Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig
  gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst.

caldav-selbsttest.py: 62 -> 86 Pruefungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 00:03:08 +02:00
1 parent df9d2d6ded
commit 8fdde182b7
11 files changed
+728 -33

No files matched your search

+109
View File
@@ -16,6 +16,7 @@ import http.cookiejar
import json
import re
import sys
import uuid
import urllib.error
import urllib.request
@@ -423,6 +424,114 @@ try:
headers={"Depth": "0", "Content-Type": "application/xml"})
check("fremder Benutzerpfad: 404", st == 404, st)
# ── Abgleich nur der Änderungen (RFC 6578) ──────────────────────────
print("\n── sync-collection ──")
def sync(token, pfad=CAL_PFAD, level="1", mit_daten=False):
daten = '<c:calendar-data/>' if mit_daten else ''
body = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
f'<d:sync-token>{token}</d:sync-token>'
f'<d:sync-level>{level}</d:sync-level>'
f'<d:prop><d:getetag/>{daten}</d:prop>'
'</d:sync-collection>')
return dav("REPORT", pfad, body, {"Depth": "1", "Content-Type": "application/xml"})
def token_von(xml_text):
m = re.search(r"<sync-token[^>]*>([^<]+)</sync-token>", xml_text)
return m.group(1) if m else ""
def geloeschte(xml_text):
"""hrefs, deren <response> einen 404-Status **ohne** propstat trägt."""
out = []
for teil in xml_text.split("<response>")[1:]:
if "404" in teil and "<propstat" not in teil:
m = re.search(r"<href[^>]*>([^<]+)</href>", teil)
if m:
out.append(m.group(1))
return out
# Der Kalender muss sich zuerst als tauglich melden – sonst probiert es
# kein Client aus.
st, _, body = propfind(CAL_PFAD, "<d:sync-token/><d:supported-report-set/>")
check("PROPFIND liefert ein sync-token", "<sync-token" in body and "urn:aether:sync:" in body,
body[:160])
check("supported-report-set nennt sync-collection", "sync-collection" in body, body[:200])
st, _, body = sync("")
check("erster Abgleich ohne Token: 207", st == 207, st)
erst_token = token_von(body)
check("liefert ein Token", erst_token.startswith("urn:aether:sync:"), erst_token)
check("liefert den vorhandenen Bestand", len(hrefs(body)) >= 1, len(hrefs(body)))
check("beim ersten Mal keine Löschmeldungen", geloeschte(body) == [], geloeschte(body))
# Nichts getan – der zweite Abgleich muss leer sein. Das ist der ganze
# Sinn der Übung: kein Verkehr, wenn sich nichts geändert hat.
st, _, body = sync(erst_token)
check("ohne Änderung kommt nichts zurück", st == 207 and hrefs(body) == [], hrefs(body))
check("Token bleibt gleich", token_von(body) == erst_token, token_von(body))
# Ein neuer Termin – und **nur** der darf kommen.
neu_uid = "sync-" + uuid.uuid4().hex[:8]
neu_pfad = f"{CAL_PFAD}{neu_uid}.ics"
st, _, _ = dav("PUT", neu_pfad,
ICS.format(uid=neu_uid, start="20260714T090000Z", end="20260714T100000Z",
summary="Sync-Probe"),
{"Content-Type": "text/calendar"})
check("Termin angelegt", st in (201, 204), st)
st, _, body = sync(erst_token)
treffer = hrefs(body)
check("genau ein geänderter Termin", len(treffer) == 1, treffer)
check("und zwar der richtige", treffer and treffer[0].endswith(f"{neu_uid}.ics"), treffer)
check("mit ETag in Anführungszeichen", re.search(r'<getetag[^>]*>"[^"]+"</getetag>', body) is not None,
body[:200])
zweit_token = token_von(body)
check("neues Token ist ein anderes", zweit_token != erst_token, zweit_token)
# Daten gleich mitliefern, wenn der Client danach fragt.
st, _, body = sync(erst_token, mit_daten=True)
check("calendar-data auf Wunsch dabei", "BEGIN:VCALENDAR" in body and "Sync-Probe" in body,
body[:200])
# Löschen: Der nächste Abgleich muss es melden – sonst bliebe der Termin
# auf jedem Gerät stehen, das gerade nicht verbunden war.
st, _, _ = dav("DELETE", neu_pfad)
check("Termin gelöscht", st in (200, 204), st)
st, _, body = sync(zweit_token)
weg = geloeschte(body)
check("Löschung wird gemeldet", len(weg) == 1 and weg[0].endswith(f"{neu_uid}.ics"), weg)
check("Löschung ohne propstat (404 an der Antwort)",
body.count("<propstat") == 0 or "<propstat" not in body.split("<response>")[1], body[:250])
# Wer von vorn anfängt, darf keine Grabsteine sehen: Er kennt den Termin
# gar nicht, eine Löschmeldung dafür wäre Unsinn.
st, _, body = sync("")
check("Vollabgleich zeigt keine Grabsteine", geloeschte(body) == [], geloeschte(body))
print("\n── sync-collection: was schiefgehen darf ──")
st, _, body = sync(erst_token, level="")
check("ohne sync-level: 400", st == 400, st)
st, _, body = sync("urn:aether:sync:abc")
check("unlesbares Token: 403 valid-sync-token",
st == 403 and "valid-sync-token" in body, f"{st} {body[:120]}")
st, _, body = sync("urn:fremd:4711")
check("Token einer fremden Instanz: 403", st == 403 and "valid-sync-token" in body, st)
st, _, body = sync("urn:aether:sync:999999999")
check("Token aus der Zukunft: 403", st == 403 and "valid-sync-token" in body, st)
st, _, body = sync(erst_token, pfad=f"{CAL_PFAD}{neu_uid}.ics")
check("sync-collection auf einem Termin: 403", st == 403, st)
# calendar-query muss weiterhin die Bibliothek bedienen – der Umweg für
# sync-collection darf die anderen Berichte nicht abschneiden.
abfrage = ('<?xml version="1.0" encoding="utf-8"?>'
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
'<d:prop><d:getetag/></d:prop>'
'<c:filter><c:comp-filter name="VCALENDAR"/></c:filter></c:calendar-query>')
st, _, body = dav("REPORT", CAL_PFAD, abfrage,
{"Depth": "1", "Content-Type": "application/xml"})
check("calendar-query geht weiterhin durch", st == 207, st)
print("\n── Löschen ──")
st, _, _ = dav("DELETE", pfad)
check("DELETE räumt weg (204)", st in (200, 204), st)