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

+33 -4
View File
@@ -42,9 +42,17 @@ const (
// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher.
const maxPropfindBody = 1 << 20
type wantedExtras struct{ ctag, color bool }
// Vier Eigenschaften, die go-webdav nicht kennt. Die ersten beiden sind
// herstellereigen, die letzten beiden stehen im RFC – die Bibliothek liefert
// sie trotzdem nicht.
type wantedExtras struct{ ctag, color, syncToken, reports bool }
func (w wantedExtras) any() bool { return w.ctag || w.color }
func (w wantedExtras) any() bool { return w.ctag || w.color || w.syncToken || w.reports }
// alleExtras: Antwort auf <allprop> und auf einen leeren Rumpf.
func alleExtras() wantedExtras {
return wantedExtras{ctag: true, color: true, syncToken: true, reports: true}
}
// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind,
// und baut den Rumpf ohne sie neu auf.
@@ -55,7 +63,7 @@ func (w wantedExtras) any() bool { return w.ctag || w.color }
func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
// Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1).
if len(bytes.TrimSpace(body)) == 0 {
return wantedExtras{ctag: true, color: true}, body
return alleExtras(), body
}
dec := xml.NewDecoder(bytes.NewReader(body))
@@ -72,7 +80,7 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Space == "DAV:" && t.Name.Local == "allprop" {
return wantedExtras{ctag: true, color: true}, body
return alleExtras(), body
}
if !inProp {
if t.Name.Space == "DAV:" && t.Name.Local == "prop" {
@@ -89,6 +97,10 @@ func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) {
want.ctag = true
case t.Name.Space == nsApple && t.Name.Local == "calendar-color":
want.color = true
case t.Name.Space == "DAV:" && t.Name.Local == "sync-token":
want.syncToken = true
case t.Name.Space == "DAV:" && t.Name.Local == "supported-report-set":
want.reports = true
default:
keep = append(keep, t.Name)
}
@@ -159,6 +171,15 @@ func appleColor(hex string) string {
return strings.ToUpper(h) + "FF"
}
// Die Berichte, die dieser Server beantwortet. Ohne diese Liste probiert ein
// Client sync-collection gar nicht erst aus – DAVx5 sieht hier nach, bevor es
// entscheidet, ob es die volle Terminliste holt oder nur die Änderungen.
const supportedReports = `<supported-report-set xmlns="DAV:">` +
`<supported-report><report><sync-collection/></report></supported-report>` +
`<supported-report><report><C:calendar-query xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
`<supported-report><report><C:calendar-multiget xmlns:C="` + nsCalDAV + `"/></report></supported-report>` +
`</supported-report-set>`
func extrasPropstat(cal *Calendar, want wantedExtras) string {
var b strings.Builder
if want.ctag {
@@ -169,6 +190,14 @@ func extrasPropstat(cal *Calendar, want wantedExtras) string {
fmt.Fprintf(&b, `<calendar-color xmlns="%s">%s</calendar-color>`, nsApple, c)
}
}
if want.syncToken {
// Derselbe Zähler wie im ctag – eine Zahl, zwei Namen, weil die
// Clients historisch nach beidem fragen.
fmt.Fprintf(&b, `<sync-token xmlns="DAV:">%s</sync-token>`, syncToken(cal.CTag))
}
if want.reports {
b.WriteString(supportedReports)
}
if b.Len() == 0 {
return ""
}