diff --git a/backend/db/migrations/000024_calendar_sync.down.sql b/backend/db/migrations/000024_calendar_sync.down.sql
new file mode 100644
index 0000000..3800c0b
--- /dev/null
+++ b/backend/db/migrations/000024_calendar_sync.down.sql
@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS cal_tombstones;
+DROP INDEX IF EXISTS cal_objects_sync;
+ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
+ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
diff --git a/backend/db/migrations/000024_calendar_sync.up.sql b/backend/db/migrations/000024_calendar_sync.up.sql
new file mode 100644
index 0000000..550c689
--- /dev/null
+++ b/backend/db/migrations/000024_calendar_sync.up.sql
@@ -0,0 +1,38 @@
+-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
+--
+-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
+-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
+-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
+--
+-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
+-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
+-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
+-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
+-- bei dem es zuletzt angefasst wurde.
+ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
+
+-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
+-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
+-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
+UPDATE cal_objects o SET sync_seq = c.ctag
+ FROM cal_calendars c WHERE c.id = o.calendar_id;
+
+CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
+
+-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
+-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
+-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
+-- Art, weil er unsichtbar ist.
+CREATE TABLE IF NOT EXISTS cal_tombstones (
+ calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
+ uid TEXT NOT NULL,
+ sync_seq BIGINT NOT NULL,
+ deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (calendar_id, uid)
+);
+CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
+
+-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
+-- aelteren Token bekommt eine klare Absage () und macht
+-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
+ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go
index 0ee953b..d43d72e 100644
--- a/backend/internal/calendar/handler.go
+++ b/backend/internal/calendar/handler.go
@@ -102,6 +102,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "PROPPATCH":
h.handleProppatch(w, r, user)
return
+ case "REPORT":
+ // Nur sync-collection gehört uns; calendar-query und
+ // calendar-multiget beantwortet die Bibliothek weiterhin selbst.
+ if h.handleReport(w, r, user) {
+ return
+ }
case http.MethodDelete:
// Nur das Löschen eines *Kalenders* gehört uns; einen Termin löscht
// die Bibliothek weiterhin selbst.
diff --git a/backend/internal/calendar/model.go b/backend/internal/calendar/model.go
index 1cead5b..4aaccfa 100644
--- a/backend/internal/calendar/model.go
+++ b/backend/internal/calendar/model.go
@@ -27,9 +27,14 @@ type Calendar struct {
// PathSegment ist die Adresse, unter der ein Client diesen Kalender
// angelegt hat ("…/calendars/max/arbeit/"). Leer heißt: keine gewählte
// Adresse, es gilt die UUID. Siehe Ref.
- PathSegment string `json:"-"`
- CTag int64 `json:"ctag"`
- CreatedAt time.Time `json:"created_at"`
+ PathSegment string `json:"-"`
+ CTag int64 `json:"ctag"`
+ // SyncFloor: Bis zu diesem Stand sind Grabsteine weggeräumt. Ein Token
+ // darunter lässt sich nicht mehr vollständig beantworten – der Client
+ // bekommt dann eine Absage und gleicht vollständig ab. Siehe
+ // Store.ChangesSince.
+ SyncFloor int64 `json:"-"`
+ CreatedAt time.Time `json:"created_at"`
}
// Ref ist das Pfadsegment, unter dem dieser Kalender in CalDAV erreichbar
diff --git a/backend/internal/calendar/propfind.go b/backend/internal/calendar/propfind.go
index a0ec608..b119316 100644
--- a/backend/internal/calendar/propfind.go
+++ b/backend/internal/calendar/propfind.go
@@ -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 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 = `` +
+ `` +
+ `` +
+ `` +
+ ``
+
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, `%s`, 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, `%s`, syncToken(cal.CTag))
+ }
+ if want.reports {
+ b.WriteString(supportedReports)
+ }
if b.Len() == 0 {
return ""
}
diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go
index 8802d42..cf2384d 100644
--- a/backend/internal/calendar/store.go
+++ b/backend/internal/calendar/store.go
@@ -29,7 +29,7 @@ func NewStore(db *pgxpool.Pool) *Store {
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
rows, err := s.db.Query(ctx,
- `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
+ `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
ownerID,
)
@@ -41,7 +41,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
var out []Calendar
for rows.Next() {
var c Calendar
- if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt); err != nil {
+ if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan calendar: %w", err)
}
out = append(out, c)
@@ -52,10 +52,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
- `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
+ `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
ownerID, id,
- ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
+ ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -75,13 +75,13 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
- `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
+ `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
FROM cal_calendars
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
ORDER BY path_segment NULLS LAST
LIMIT 1`,
ownerID, ref,
- ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
+ ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -102,9 +102,9 @@ func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment
err := s.db.QueryRow(ctx,
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
VALUES ($1, $2, $3, $4, $5)
- RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
+ RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, name, color, description, segment,
- ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
+ ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if isUniqueViolation(err) {
return nil, ErrConflict
}
@@ -142,9 +142,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col
err := s.db.QueryRow(ctx,
`INSERT INTO cal_calendars (owner_id, name, color, description)
VALUES ($1, $2, $3, $4)
- RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
+ RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, name, color, description,
- ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
+ ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert calendar: %w", err)
}
@@ -164,9 +164,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name,
description = COALESCE($5, description),
timezone = COALESCE($6, timezone)
WHERE owner_id = $1 AND id = $2
- RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at`,
+ RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
ownerID, id, name, color, description, timezone,
- ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
+ ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -248,22 +248,35 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon
}
defer tx.Rollback(ctx)
+ // Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den
+ // Stand tragen, auf den der Kalender damit springt.
+ seq, err := bumpSeq(ctx, tx, calendarID)
+ if err != nil {
+ return nil, err
+ }
+
var o Object
err = tx.QueryRow(ctx,
- `INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, now())
+ `INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (calendar_id, uid) DO UPDATE
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
- dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now()
+ dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
+ sync_seq = EXCLUDED.sync_seq, updated_at = now()
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
- calendarID, uid, component, data, etag, dtstart, dtend,
+ calendarID, uid, component, data, etag, dtstart, dtend, seq,
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert object: %w", err)
}
- if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
- return nil, fmt.Errorf("bump ctag: %w", err)
+ // Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
+ // Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten
+ // Abgleich beides zu sehen -- die Datei und ihre Todesmeldung.
+ if _, err := tx.Exec(ctx,
+ `DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid,
+ ); err != nil {
+ return nil, fmt.Errorf("clear tombstone: %w", err)
}
if err := tx.Commit(ctx); err != nil {
@@ -303,11 +316,22 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
return nil, ErrConflict
}
+ // Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin
+ // fort (Grabstein), im Ziel ist er neu.
+ fromSeq, err := bumpSeq(ctx, tx, fromCal)
+ if err != nil {
+ return nil, err
+ }
+ toSeq, err := bumpSeq(ctx, tx, toCal)
+ if err != nil {
+ return nil, err
+ }
+
row := tx.QueryRow(ctx,
- `UPDATE cal_objects SET calendar_id = $1, updated_at = now()
- WHERE calendar_id = $2 AND uid = $3
+ `UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
+ WHERE calendar_id = $3 AND uid = $4
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
- toCal, fromCal, uid,
+ toCal, toSeq, fromCal, uid,
)
o, err := scanObject(row)
if errors.Is(err, pgx.ErrNoRows) {
@@ -317,10 +341,14 @@ func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid st
return nil, err
}
+ if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil {
+ return nil, err
+ }
+ // Im Ziel darf kein alter Grabstein derselben UID stehen bleiben.
if _, err := tx.Exec(ctx,
- `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal,
+ `DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
); err != nil {
- return nil, fmt.Errorf("bump ctag: %w", err)
+ return nil, fmt.Errorf("clear tombstone: %w", err)
}
if err := tx.Commit(ctx); err != nil {
@@ -343,8 +371,171 @@ func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid stri
if tag.RowsAffected() == 0 {
return ErrNotFound
}
- if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
- return fmt.Errorf("bump ctag: %w", err)
+ seq, err := bumpSeq(ctx, tx, calendarID)
+ if err != nil {
+ return err
+ }
+ if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil {
+ return err
+ }
+ if err := pruneTombstones(ctx, tx, calendarID); err != nil {
+ return err
}
return tx.Commit(ctx)
}
+
+// ── Abgleich nur der Änderungen (RFC 6578) ───────────────────────────────────
+
+// Wie lange ein Grabstein stehen bleibt.
+//
+// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann
+// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen;
+// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer.
+// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine
+// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät.
+const tombstoneTTL = 180 * 24 * time.Hour
+
+type execer interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+ QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
+}
+
+// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen.
+//
+// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen
+// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über
+// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander.
+func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) {
+ var seq int64
+ if err := tx.QueryRow(ctx,
+ `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID,
+ ).Scan(&seq); err != nil {
+ return 0, fmt.Errorf("bump ctag: %w", err)
+ }
+ return seq, nil
+}
+
+func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error {
+ _, err := tx.Exec(ctx,
+ `INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at)
+ VALUES ($1, $2, $3, now())
+ ON CONFLICT (calendar_id, uid) DO UPDATE
+ SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`,
+ calendarID, uid, seq)
+ if err != nil {
+ return fmt.Errorf("set tombstone: %w", err)
+ }
+ return nil
+}
+
+// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an.
+//
+// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis
+// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die
+// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort,
+// in der eine Löschung einfach fehlt, und behielte den Termin für immer.
+//
+// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen
+// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt.
+func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error {
+ // Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der
+ // ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die
+ // erste Zeile greift, setzt den Boden auf irgendeinen der geräumten
+ // Stände – und ein Client mit einem Token dazwischen bekäme dann eine
+ // Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele.
+ var hoechste *int64
+ if err := tx.QueryRow(ctx,
+ `WITH geraeumt AS (
+ DELETE FROM cal_tombstones
+ WHERE calendar_id = $1 AND deleted_at < now() - $2::interval
+ RETURNING sync_seq
+ )
+ SELECT max(sync_seq) FROM geraeumt`,
+ calendarID, tombstoneTTL.String(),
+ ).Scan(&hoechste); err != nil {
+ return fmt.Errorf("prune tombstones: %w", err)
+ }
+ if hoechste == nil {
+ return nil
+ }
+ if _, err := tx.Exec(ctx,
+ `UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`,
+ calendarID, *hoechste,
+ ); err != nil {
+ return fmt.Errorf("raise sync floor: %w", err)
+ }
+ return nil
+}
+
+// Changes ist die Antwort auf „was hat sich seit Stand N getan".
+type Changes struct {
+ // Angelegt oder geändert – beides sieht für den Client gleich aus, er
+ // holt das Objekt in beiden Fällen neu.
+ Changed []Object
+ // UIDs, die es nicht mehr gibt.
+ Deleted []string
+ // Der Stand, auf dem der Client danach steht.
+ Token int64
+}
+
+// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan
+// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen
+// Bestand – genau das, was ein Client beim ersten Abgleich braucht.
+//
+// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind
+// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre
+// unvollständig, ohne dass es jemandem auffiele.
+func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) {
+ if since < 0 || since > cal.CTag {
+ // Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von
+ // einer anderen Instanz oder aus einer wiederhergestellten Sicherung.
+ return nil, false, nil
+ }
+ if since > 0 && since < cal.SyncFloor {
+ return nil, false, nil
+ }
+
+ out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}}
+
+ rows, err := s.db.Query(ctx,
+ `SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
+ FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
+ cal.ID, since)
+ if err != nil {
+ return nil, false, fmt.Errorf("query changed objects: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ o, err := scanObject(rows)
+ if err != nil {
+ return nil, false, err
+ }
+ out.Changed = append(out.Changed, o)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, false, err
+ }
+
+ // Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt:
+ // Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm
+ // nur Löschungen für Termine schicken, die er nie gesehen hat.
+ if since == 0 {
+ return out, true, nil
+ }
+
+ trows, err := s.db.Query(ctx,
+ `SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
+ cal.ID, since)
+ if err != nil {
+ return nil, false, fmt.Errorf("query tombstones: %w", err)
+ }
+ defer trows.Close()
+ for trows.Next() {
+ var uid string
+ if err := trows.Scan(&uid); err != nil {
+ return nil, false, err
+ }
+ out.Deleted = append(out.Deleted, uid)
+ }
+ return out, true, trows.Err()
+}
diff --git a/backend/internal/calendar/sync.go b/backend/internal/calendar/sync.go
new file mode 100644
index 0000000..e63c0c4
--- /dev/null
+++ b/backend/internal/calendar/sync.go
@@ -0,0 +1,225 @@
+package calendar
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "aether/internal/auth"
+)
+
+// Abgleich nur der Änderungen: REPORT sync-collection, RFC 6578.
+//
+// **Wozu.** Bis hierher lief jeder Durchgang so: Der Client fragt das ctag
+// ab, sieht es verändert und holt daraufhin die *volle* Terminliste. Eine
+// verschobene Besprechung kostet damit denselben Verkehr wie ein Neuanfang –
+// bei einem Jahr Termine jedes Mal. Am Telefon zahlt man das in Akku und
+// Datenvolumen, und zwar bei jedem der viertelstündlichen Durchgänge.
+//
+// Mit sync-collection sagt der Client stattdessen „ich bin auf Stand 41" und
+// bekommt zurück, was seither geschah – und ein neues Token.
+//
+// **Warum hier und nicht in der Bibliothek.** go-webdav kennt für REPORT nur
+// calendar-query und calendar-multiget; alles andere beantwortet es mit 400.
+// Der Weg geht deshalb an ihr vorbei, wie schon bei PROPFIND und MKCALENDAR.
+//
+// **Was ein Token ist.** Eine Zahl: der Stand des Kalenders, also sein ctag.
+// Kein Zeitstempel – zwei Änderungen in derselben Sekunde wären damit nicht
+// zu trennen, und eine Uhr, die zurückspringt, ließe Änderungen verschwinden.
+
+// Präfix des Tokens. Ein Token ist laut RFC ein URI; der Präfix macht
+// zugleich erkennbar, dass ein Token einer *fremden* Instanz nicht passt.
+const syncTokenPrefix = "urn:aether:sync:"
+
+// Ein sync-collection-Rumpf ist eine Handvoll Elemente. Alles darüber ist
+// kein Abgleich mehr.
+const maxReportBody = 1 << 20
+
+type syncReport struct {
+ XMLName xml.Name `xml:"DAV: sync-collection"`
+ SyncToken string `xml:"DAV: sync-token"`
+ SyncLevel string `xml:"DAV: sync-level"`
+ Limit *struct {
+ NResults int `xml:"DAV: nresults"`
+ } `xml:"DAV: limit"`
+ Prop struct {
+ Any []struct {
+ XMLName xml.Name
+ } `xml:",any"`
+ } `xml:"DAV: prop"`
+}
+
+// wantsCalendarData: Fragt der Client die Termindaten gleich mit ab? Die
+// meisten holen erst die ETags und danach gezielt per multiget – wer aber
+// beides in einem Zug will, soll es bekommen.
+func (s *syncReport) wantsCalendarData() bool {
+ for _, el := range s.Prop.Any {
+ if el.XMLName.Space == nsCalDAV && el.XMLName.Local == "calendar-data" {
+ return true
+ }
+ }
+ return false
+}
+
+const nsCalDAV = "urn:ietf:params:xml:ns:caldav"
+
+// handleReport nimmt sync-collection an sich und lässt alles andere durch.
+// Rückgabe: true, wenn die Antwort geschrieben wurde.
+func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, user *auth.User) bool {
+ body, err := io.ReadAll(io.LimitReader(r.Body, maxReportBody))
+ r.Body.Close()
+ // Was wir nicht selbst beantworten, muss die Bibliothek unverändert
+ // vorfinden – also den gelesenen Rumpf wieder unterlegen.
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ if err != nil || !isSyncCollection(body) {
+ return false
+ }
+
+ var req syncReport
+ if err := xml.Unmarshal(body, &req); err != nil {
+ http.Error(w, "malformed sync-collection request", http.StatusBadRequest)
+ return true
+ }
+
+ // sync-level ist Pflicht (RFC 6578, 3.3). Bei einem Kalender gibt es nur
+ // eine Ebene; "infinite" bedeutet hier dasselbe wie "1" und wird
+ // angenommen, statt den Client an einer Formalie scheitern zu lassen.
+ if lvl := strings.TrimSpace(req.SyncLevel); lvl != "1" && lvl != "infinite" {
+ http.Error(w, "sync-level must be 1", http.StatusBadRequest)
+ return true
+ }
+
+ ref, rest, err := parseCalendarPath(r.URL.Path)
+ if err != nil || rest != "" {
+ // sync-collection gilt für eine Sammlung, nicht für einen Termin.
+ http.Error(w, "sync-collection applies to a calendar collection", http.StatusForbidden)
+ return true
+ }
+ cal, err := h.store.GetCalendarByRef(r.Context(), user.ID, ref)
+ if err != nil {
+ http.Error(w, "Not Found", http.StatusNotFound)
+ return true
+ }
+
+ since, ok := parseSyncToken(req.SyncToken)
+ if !ok {
+ writeInvalidToken(w)
+ return true
+ }
+
+ changes, gueltig, err := h.store.ChangesSince(r.Context(), cal, since)
+ if err != nil {
+ http.Error(w, "sync failed", http.StatusInternalServerError)
+ return true
+ }
+ if !gueltig {
+ // Das Token ist zu alt oder stammt nicht von uns. Eine unvollständige
+ // Antwort wäre hier das Schlimmste: Der Client hielte sie für den
+ // ganzen Unterschied und behielte gelöschte Termine für immer.
+ writeInvalidToken(w)
+ return true
+ }
+
+ writeSyncResponse(w, user, cal, changes, req.wantsCalendarData())
+ return true
+}
+
+// isSyncCollection sieht nach, ob das Wurzelelement sync-collection ist –
+// ohne den ganzen Rumpf zu deuten. Alles andere geht unverändert weiter an
+// go-webdav.
+func isSyncCollection(body []byte) bool {
+ dec := xml.NewDecoder(bytes.NewReader(body))
+ for {
+ tok, err := dec.Token()
+ if err != nil {
+ return false
+ }
+ if start, ok := tok.(xml.StartElement); ok {
+ return start.Name.Space == "DAV:" && start.Name.Local == "sync-collection"
+ }
+ }
+}
+
+// parseSyncToken liest den Stand aus dem Token. Ein leeres Token heißt
+// „ich fange bei null an" – der erste Abgleich eines Clients.
+func parseSyncToken(tok string) (int64, bool) {
+ tok = strings.TrimSpace(tok)
+ if tok == "" {
+ return 0, true
+ }
+ if !strings.HasPrefix(tok, syncTokenPrefix) {
+ return 0, false
+ }
+ n, err := strconv.ParseInt(strings.TrimPrefix(tok, syncTokenPrefix), 10, 64)
+ if err != nil || n < 0 {
+ return 0, false
+ }
+ return n, true
+}
+
+func syncToken(seq int64) string {
+ return syncTokenPrefix + strconv.FormatInt(seq, 10)
+}
+
+// writeInvalidToken schickt die Absage, die der RFC dafür vorsieht. Sie ist
+// kein Fehler im üblichen Sinn: Jeder Client kennt sie und macht daraufhin
+// einen Vollabgleich – genau das, was wir wollen.
+func writeInvalidToken(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.WriteHeader(http.StatusForbidden)
+ io.WriteString(w, xml.Header+
+ ``)
+}
+
+func writeSyncResponse(w http.ResponseWriter, user *auth.User,
+ cal *Calendar, changes *Changes, withData bool) {
+
+ var b strings.Builder
+ b.WriteString(xml.Header)
+ b.WriteString(``)
+
+ for i := range changes.Changed {
+ o := &changes.Changed[i]
+ // Die Anführungszeichen stehen **außerhalb** der Maskierung: Sonst
+ // macht xml.EscapeText daraus `"`. Gültiges XML – aber genug
+ // CalDAV-Clients vergleichen ETags als rohe Zeichenkette, und die
+ // sähen dann bei jedem Durchgang eine Änderung, wo keine ist.
+ fmt.Fprintf(&b, `%s%s`,
+ xmlText(objectPath(user.Username, cal.Ref(), o.UID)), quoteETag(o.ETag))
+ if withData {
+ fmt.Fprintf(&b, `%s`, xmlText(o.Data))
+ }
+ b.WriteString(`HTTP/1.1 200 OK`)
+ }
+
+ // Gelöschtes trägt **keinen** propstat, sondern einen Status direkt an der
+ // Antwort – so unterscheidet der Client „ist weg" von „gibt es, konnte
+ // aber eine Eigenschaft nicht liefern".
+ for _, uid := range changes.Deleted {
+ fmt.Fprintf(&b, `%sHTTP/1.1 404 Not Found`,
+ xmlText(objectPath(user.Username, cal.Ref(), uid)))
+ }
+
+ fmt.Fprintf(&b, `%s`, syncToken(changes.Token))
+
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.WriteHeader(http.StatusMultiStatus)
+ io.WriteString(w, b.String())
+}
+
+// quoteETag: In der Datenbank steht der nackte Wert, über die Leitung gehört
+// er in Anführungszeichen (RFC 9110). go-webdav macht das an anderer Stelle
+// selbst – hier bauen wir die Antwort von Hand, also hier auch.
+//
+// Der Inhalt wird maskiert, die Anführungszeichen nicht: Sie sind Teil der
+// Syntax, nicht des Werts.
+func quoteETag(etag string) string {
+ if strings.HasPrefix(etag, `"`) || strings.HasPrefix(etag, `W/`) {
+ return xmlText(etag)
+ }
+ return `"` + xmlText(etag) + `"`
+}
diff --git a/backend/pkg/db/migrations/000024_calendar_sync.down.sql b/backend/pkg/db/migrations/000024_calendar_sync.down.sql
new file mode 100644
index 0000000..3800c0b
--- /dev/null
+++ b/backend/pkg/db/migrations/000024_calendar_sync.down.sql
@@ -0,0 +1,4 @@
+DROP TABLE IF EXISTS cal_tombstones;
+DROP INDEX IF EXISTS cal_objects_sync;
+ALTER TABLE cal_objects DROP COLUMN IF EXISTS sync_seq;
+ALTER TABLE cal_calendars DROP COLUMN IF EXISTS sync_floor;
diff --git a/backend/pkg/db/migrations/000024_calendar_sync.up.sql b/backend/pkg/db/migrations/000024_calendar_sync.up.sql
new file mode 100644
index 0000000..550c689
--- /dev/null
+++ b/backend/pkg/db/migrations/000024_calendar_sync.up.sql
@@ -0,0 +1,38 @@
+-- Abgleich nur der Aenderungen (RFC 6578, sync-collection).
+--
+-- Bis hierher holte jeder Durchgang die **volle** Terminliste: Der Client
+-- fragt das ctag ab, sieht es veraendert und laedt alles neu. Bei einem Jahr
+-- Termine ist das jedes Mal dieselbe Menge Daten fuer eine Aenderung.
+--
+-- Was dafuer fehlt, ist eine Reihenfolge: "was hat sich seit Stand N getan".
+-- Ein **zweiter** Zaehler waere dafuer nicht noetig -- das ctag ist bereits
+-- genau das: eine Zahl je Kalender, die bei jedem Schreiben um eins steigt.
+-- Sie wird deshalb hier zur Sync-Sequenz; jedes Objekt merkt sich den Stand,
+-- bei dem es zuletzt angefasst wurde.
+ALTER TABLE cal_objects ADD COLUMN IF NOT EXISTS sync_seq BIGINT NOT NULL DEFAULT 0;
+
+-- Bestehende Objekte auf den heutigen Stand ihres Kalenders setzen. Ein
+-- Client hat noch kein Token (die Eigenschaft gab es bis jetzt nicht), holt
+-- also ohnehin einmal alles und bekommt dabei sein erstes Token.
+UPDATE cal_objects o SET sync_seq = c.ctag
+ FROM cal_calendars c WHERE c.id = o.calendar_id;
+
+CREATE INDEX IF NOT EXISTS cal_objects_sync ON cal_objects (calendar_id, sync_seq);
+
+-- Grabsteine: Eine geloeschte Zeile kann nicht mehr erzaehlen, dass es sie
+-- gab. Ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das
+-- waehrend des Loeschens nicht verbunden war -- der teuerste Fehler dieser
+-- Art, weil er unsichtbar ist.
+CREATE TABLE IF NOT EXISTS cal_tombstones (
+ calendar_id UUID NOT NULL REFERENCES cal_calendars(id) ON DELETE CASCADE,
+ uid TEXT NOT NULL,
+ sync_seq BIGINT NOT NULL,
+ deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (calendar_id, uid)
+);
+CREATE INDEX IF NOT EXISTS cal_tombstones_sync ON cal_tombstones (calendar_id, sync_seq);
+
+-- Bis zu diesem Stand sind Grabsteine weggeraeumt. Ein Client mit einem
+-- aelteren Token bekommt eine klare Absage () und macht
+-- einen Vollabgleich -- besser als eine Antwort, in der Loeschungen fehlen.
+ALTER TABLE cal_calendars ADD COLUMN IF NOT EXISTS sync_floor BIGINT NOT NULL DEFAULT 0;
diff --git a/kalender-plan.md b/kalender-plan.md
index 1e39acb..e8fddfa 100644
--- a/kalender-plan.md
+++ b/kalender-plan.md
@@ -86,7 +86,7 @@ Die abgeleiteten Spalten sind nur zum Vorsortieren da.
| **1** | CalDAV lesend und schreibend, ein Kalender, Anmeldung über App-Passwörter, Auffindpfade (`/.well-known/caldav`, `current-user-principal`, `calendar-home-set`). Ziel: DAVx5 gleicht ab. | ✅ |
| **2** | Mehrere Kalender, Farbe, Umbenennen, Löschen. Export als `.ics`, Import einer bestehenden Datei. | ✅ |
| **3** | Weboberfläche in Aether: Monat, Woche, Liste; Termin anlegen, ändern, löschen. Der Teil, der Spaß macht – und dessen Fehler man **sieht**. | ✅ |
-| **4** | `sync-collection` statt Vollabgleich, Aufgabenansicht, Teilen mit anderen Nutzern, öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | offen |
+| **4** | `sync-collection` ✅, Aufgabenansicht, Teilen mit anderen Nutzern, öffentlicher Abo-Link, Kalender in der HoneyOS-Leiste. | läuft |
| **5** | Kontakte (CardDAV). Dieselbe Maschinerie, dieselbe Bibliothek – und **einfacher**, weil es dort keine Wiederholungen gibt. | später entscheiden |
Über die Phasen hinaus dazugekommen, weil es sich beim Prüfen als nötig
@@ -109,6 +109,52 @@ erwies:
Wege führen auf dieselben Daten, aber über völlig verschiedenen Code – ein
grüner Lauf des einen sagt nichts über den anderen.
+## Abgleich nur der Änderungen (Phase 4, erledigt 12.09.2026)
+
+Bis hierher lief jeder Durchgang so: Der Client fragt das `ctag` ab, sieht es
+verändert und holt daraufhin die **volle** Terminliste. Eine verschobene
+Besprechung kostet damit denselben Verkehr wie ein Neuanfang – bei einem Jahr
+Termine, jedes Mal, alle fünfzehn Minuten. Bezahlt wird das in Akku und
+Datenvolumen des Telefons.
+
+`sync-collection` (RFC 6578) dreht das um: Der Client sagt „ich bin auf Stand
+41" und bekommt zurück, was seither geschah, plus ein neues Token.
+
+**Kein zweiter Zähler.** Das `ctag` *ist* bereits eine Zahl je Kalender, die
+bei jedem Schreiben um eins steigt – also genau die Sequenz, die hier gebraucht
+wird. Jedes Objekt merkt sich in `sync_seq` den Stand, bei dem es zuletzt
+angefasst wurde. Ein eigener Sync-Zähler daneben wäre eine zweite Wahrheit über
+denselben Sachverhalt gewesen, und zwei Wahrheiten laufen auseinander.
+
+**Grabsteine.** Eine gelöschte Zeile kann nicht mehr erzählen, dass es sie gab.
+`cal_tombstones` hält deshalb UID und Stand jedes gelöschten Termins fest –
+sonst bliebe er auf jedem Gerät stehen, das während des Löschens nicht
+verbunden war. Das ist der teuerste Fehler dieser Art, weil er unsichtbar ist.
+
+Grabsteine stehen 180 Tage; beim Räumen wandert `sync_floor` mit. Ein Token
+darunter lässt sich nicht mehr vollständig beantworten, und dann ist die
+ehrliche Antwort `403 ` – der Client gleicht daraufhin voll
+ab. Eine lückenhafte Antwort wäre schlimmer als eine Absage: Der Client hielte
+sie für den ganzen Unterschied.
+
+Drei Feinheiten, die beim Bauen auffielen:
+
+- **Beim ersten Abgleich keine Grabsteine.** Wer den Kalender noch gar nicht
+ kennt, braucht keine Löschmeldungen für Termine, die er nie gesehen hat.
+- **Auferstehung.** Wird eine gelöschte UID neu angelegt, muss ihr Grabstein
+ weg – sonst bekäme der Client die Datei und ihre Todesmeldung im selben Zug.
+- **Anführungszeichen am ETag stehen außerhalb der Maskierung.** `xml.EscapeText`
+ macht daraus sonst `"`; gültiges XML, aber genug Clients vergleichen
+ ETags als rohe Zeichenkette und sähen bei jedem Durchgang eine Änderung.
+
+Dazu zwei Eigenschaften, die go-webdav gar nicht kennt und die jetzt denselben
+Weg gehen wie `getctag` und `calendar-color` (`propfind.go`): `sync-token` und
+`supported-report-set`. Ohne die zweite probiert DAVx5 `sync-collection` nicht
+einmal aus.
+
+Geprüft in `scripts/caldav-selbsttest.py` (86 statt 62 Prüfungen), darunter der
+Fall, auf den es ankommt: *ohne Änderung kommt nichts zurück*.
+
## Teilen – drei Bedeutungen, zwei davon gehen
„Geteilter Kalender" meint je nach Person etwas anderes:
diff --git a/scripts/caldav-selbsttest.py b/scripts/caldav-selbsttest.py
index a2b79ed..bd34b6f 100644
--- a/scripts/caldav-selbsttest.py
+++ b/scripts/caldav-selbsttest.py
@@ -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 = '' if mit_daten else ''
+ body = (''
+ ''
+ f'{token}'
+ f'{level}'
+ f'{daten}'
+ '')
+ return dav("REPORT", pfad, body, {"Depth": "1", "Content-Type": "application/xml"})
+
+ def token_von(xml_text):
+ m = re.search(r"]*>([^<]+)", xml_text)
+ return m.group(1) if m else ""
+
+ def geloeschte(xml_text):
+ """hrefs, deren einen 404-Status **ohne** propstat trägt."""
+ out = []
+ for teil in xml_text.split("")[1:]:
+ if "404" in teil and "]*>([^<]+)", 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, "")
+ check("PROPFIND liefert ein sync-token", "= 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']*>"[^"]+"', 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("")[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 = (''
+ ''
+ ''
+ '')
+ 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)