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 ". 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:
1 parent
df9d2d6ded
commit
8fdde182b7
11 files changed
+728
-33
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user