Der Kalender kam im README bislang mit keinem Wort vor -- weder in der
Phasentabelle noch als Abschnitt, obwohl er ein eigener Zugangsweg mit
eigener Anmeldung ist. Jetzt steht er neben WebDAV, wo er hingehoert:
gleiche Adresse-plus-App-Passwort-Logik, gleiche Sperre nach zehn
Fehlversuchen.
Zwei Dinge stehen dort ausdruecklich, weil sie beim Einrichten sonst
eine halbe Stunde Suche kosten:
- Die Kalenderfarbe holt DAVx5 nur beim Erfassen der Sammlungen, nicht
bei jedem Abgleich. Sie bleibt im Telefon alt, bis man dort einmal
"Kalenderliste aktualisieren" antippt. Am Geraet nachgemessen.
- Kalender verwalten kann die Kalender-App des Telefons gar nicht;
Android ueberlaesst das der Sync-App.
Im Plan die Phasen 1 bis 3 auf erledigt, und darunter das, was ueber die
Phasen hinaus dazukam, weil es sich beim Pruefen als noetig erwies:
Umhaengen, EXDATE, getctag/calendar-color, Kalenderverwaltung vom Client
aus, die beiden Selbsttests.
Abbild veroeffentlicht als 2026.09.06 und latest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
17 KiB
Markdown
335 lines
17 KiB
Markdown
# Kalender in Aether – Plan
|
||
|
||
Stand 02.09.2026. Ausgangspunkt: Radicale ist für einen einfachen selbst
|
||
gehosteten Kalender überraschend umständlich (Konfigurationsdateien, dünne
|
||
Doku, keine OIDC-Anbindung).
|
||
|
||
## Festgelegt (02.09.2026)
|
||
|
||
- **Mehrere Kalender** von Anfang an. Nicht weil es heute drei sein müssen,
|
||
sondern weil es jetzt eine Spalte ist und später ein Umbau.
|
||
- **Aufgaben (VTODO)** werden von Anfang an gespeichert und über CalDAV
|
||
angeboten; die Ansicht in Aether kommt später. Muss vorher feststehen, weil
|
||
jeder Kalender dem Client meldet, was er kann – nachträglich richten die
|
||
Clients ihre Konten neu ein.
|
||
- **Ändern statt neu erzeugen**, sobald unsere eigene Oberfläche schreibt.
|
||
- Anhänge sind kein Ziel. Falls doch, dann als Verweis in Aethers Dateien –
|
||
nie als eingebettete Datei in der `.ics`.
|
||
- Erinnerungen weckt die App auf dem Telefon. Browser-Benachrichtigungen in
|
||
der Weboberfläche: nett für später, kein Drang.
|
||
|
||
## Entscheidung: ein Modul in Aether, kein eigener Dienst
|
||
|
||
Der Grund ist genau das, was bei Radicale gefehlt hat.
|
||
|
||
**Ein CalDAV-Client kann kein OIDC.** DAVx5, Thunderbird und die Kalender-App
|
||
melden sich mit Benutzername und Passwort an – ein Anmeldefenster im Browser
|
||
gibt es dort nicht. Wer OIDC für CalDAV sucht, sucht etwas, das es so nicht
|
||
gibt. Die richtige Antwort sind **App-Passwörter**, und die hat Aether schon:
|
||
Das WebDAV unter `/dav/` läuft bereits darüber.
|
||
|
||
Damit ist die Rechnung einfach:
|
||
|
||
| | Radicale o. ä. | Modul in Aether |
|
||
|---|---|---|
|
||
| Anmeldung Oberfläche | eigenes Konzept, kein OIDC | PocketID, schon da |
|
||
| Anmeldung DAVx5 | eigene Nutzerdatei | App-Passwörter, schon da |
|
||
| Reverse Proxy, TLS | zusätzlicher Eintrag | schon da |
|
||
| Sicherung | zweiter Datenbestand | dieselbe Datenbank |
|
||
| Oberfläche | im Grunde keine | die, die wir bauen |
|
||
|
||
## Was die Bibliothek abnimmt
|
||
|
||
`github.com/emersion/go-webdav` (v0.7.0, Oktober 2025) bringt einen
|
||
CalDAV-Handler mit. Nachgesehen, nicht angenommen:
|
||
|
||
- Der Handler beantwortet `PROPFIND`, `OPTIONS` und `REPORT` selbst und liest
|
||
`calendar-query` wie `calendar-multiget`.
|
||
- `caldav.Filter()` wendet die Filter der Anfrage auf die Termine an.
|
||
- **Der Zeitraumfilter löst Wiederholungen auf:** `matchCompTimeRange` ruft
|
||
`comp.RecurrenceSet(loc)`, und go-ical stützt sich dafür auf
|
||
`teambition/rrule-go`. Genau der Teil, vor dem ich gewarnt hatte – „jeden
|
||
zweiten Dienstag, über die Zeitumstellung hinweg" – steckt in der Bibliothek
|
||
und nicht in unserem Code.
|
||
|
||
Zu erfüllen ist eine Schnittstelle mit **neun Methoden** plus dem
|
||
Nutzer-Principal:
|
||
|
||
CalendarHomeSetPath · CreateCalendar · ListCalendars · GetCalendar
|
||
GetCalendarObject · ListCalendarObjects · QueryCalendarObjects
|
||
PutCalendarObject · DeleteCalendarObject
|
||
|
||
Das ist Speicherarbeit, keine Protokollarbeit. Die Einschätzung „zwei Wochen
|
||
bis DAVx5 läuft, danach ein halbes Jahr Kleinkram" gilt für einen Server von
|
||
Grund auf – hier nicht.
|
||
|
||
## Datenmodell
|
||
|
||
Zwei Tabellen, mehr braucht es nicht:
|
||
|
||
- `calendars` – Besitzer, Name, Farbe, Zeitzone, `ctag` (ändert sich bei jeder
|
||
Änderung, daran erkennt der Client, dass es etwas Neues gibt).
|
||
- `calendar_objects` – Kalender, UID, **das `.ics` roh und unverändert**,
|
||
`etag`, dazu abgeleitet `dtstart`/`dtend` und ein Kennzeichen, ob eine
|
||
Wiederholung dranhängt.
|
||
|
||
**Die wichtigste Regel: das `.ics` byte-genau speichern, wie der Client es
|
||
geschickt hat.** Nicht zerlegen und neu zusammensetzen – sonst gehen
|
||
Eigenschaften verloren, die wir nicht kennen (Anhänge, Teilnehmerstatus,
|
||
Herstellerfelder), und beim nächsten Abgleich fehlt etwas, das vorher da war.
|
||
Die abgeleiteten Spalten sind nur zum Vorsortieren da.
|
||
|
||
## Phasen
|
||
|
||
| | Inhalt | Stand |
|
||
|---|---|---|
|
||
| **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 |
|
||
| **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
|
||
erwies:
|
||
|
||
- **Termin umhängen** in einen anderen Kalender, unter Erhalt der UID – per
|
||
Ziehen in der Oberfläche, am Handy mit langem Drücken.
|
||
- **Einzelnen Termin aus einer Reihe streichen** (`EXDATE`), statt nur die
|
||
ganze Reihe löschen zu können.
|
||
- **`getctag` und `calendar-color`**, die go-webdav nicht kennt: aus der
|
||
Anfrage genommen und der Antwort selbst wieder angehängt
|
||
(`internal/calendar/propfind.go`).
|
||
- **Kalender vom Client aus verwalten** – `MKCALENDAR`, erweitertes `MKCOL`,
|
||
`PROPPATCH`, `DELETE` einer Sammlung (`internal/calendar/collections.go`).
|
||
Dafür Migration 23: Bei `MKCALENDAR` sucht der Client die Adresse aus, die
|
||
seither in `path_segment` steht. Leer = es gilt weiterhin die UUID, damit
|
||
eingerichtete Geräte ihre Kalender unter der gewohnten Adresse behalten.
|
||
- **Zwei Selbsttests**, einer je Zugangsweg: `scripts/kalender-selbsttest.py`
|
||
(JSON, 36 Prüfungen) und `scripts/caldav-selbsttest.py` (CalDAV, 62). Beide
|
||
Wege führen auf dieselben Daten, aber über völlig verschiedenen Code – ein
|
||
grüner Lauf des einen sagt nichts über den anderen.
|
||
|
||
## Teilen – drei Bedeutungen, zwei davon gehen
|
||
|
||
„Geteilter Kalender" meint je nach Person etwas anderes:
|
||
|
||
1. **Derselbe Kalender auf mehreren Geräten.** Das ist der Sinn der Sache und
|
||
ab Phase 1 da.
|
||
2. **Ein Kalender, den ein anderer Nutzer sieht** – lesend oder schreibend, wie
|
||
das Teilen in Nextcloud. Geht, und es ist keine Protokollarbeit: Wir kennen
|
||
unsere Nutzer und unsere Kalender, also ist es eine Tabelle
|
||
(`kalender × nutzer × recht`) und eine Bedingung in `ListCalendars`. Der
|
||
Client sieht danach schlicht einen Kalender mehr in seiner Liste. **Phase 4.**
|
||
3. **Ein Kalender an der Adresse eines Links**, den jemand ohne Konto
|
||
abonnieren kann – nur lesend, wie ein Freigabelink für Dateien. Klein, und
|
||
Aether hat die Freigabelinks schon. **Phase 4.**
|
||
|
||
Was **nicht** kommt: Einladungen per E-Mail (iMIP/iTIP) und Frei/Belegt-Abfragen
|
||
über Serviergrenzen hinweg. Das ist der Teil, an dem Groupware groß und
|
||
kompliziert wird – und der einzige, der wirklich wehtut.
|
||
|
||
## Felder – was ein Termin mitbringt
|
||
|
||
Alles aus RFC 5545, und wir müssen dafür nichts „unterstützen": Wir speichern
|
||
die Datei unverändert, also überleben auch Felder, die wir nicht kennen.
|
||
|
||
RRULE/EXDATE Wiederholung · DESCRIPTION Notiz · LOCATION Ort
|
||
VALARM Erinnerung · CATEGORIES · CLASS privat/öffentlich
|
||
ATTENDEE Teilnehmer (gespeichert, aber niemand wird eingeladen)
|
||
DTSTART ohne Uhrzeit = ganztägig
|
||
|
||
**Die Regel, die Daten rettet:** Sobald unsere Oberfläche einen Termin ändert,
|
||
wird die vorhandene Datei eingelesen, die angefassten Felder ersetzt und der
|
||
Rest unberührt zurückgeschrieben. Nie neu erzeugen. Sonst verschiebt man am
|
||
Rechner einen Termin um eine Stunde und die Erinnerung, die das Telefon
|
||
angelegt hatte, ist weg – und das fällt erst Wochen später auf.
|
||
|
||
## Clients, gegen die geprüft wird
|
||
|
||
| Client | Prüft |
|
||
|---|---|
|
||
| **DAVx5 → Samsung Kalender** | der Hauptweg. DAVx5 schreibt in Androids Kalenderspeicher, Samsung Kalender liest daraus. In der App unter „Kalender verwalten" einmal sichtbar schalten, und DAVx5 von der Akkuoptimierung ausnehmen. |
|
||
| **Tasks.org** | Aufgaben. Androids Kalenderspeicher kennt keine – ohne so eine App bietet DAVx5 die Aufgabenlisten gar nicht erst an, und **Samsung Kalender zeigt sie nie**. |
|
||
| **Thunderbird** | der gutmütige Prüfstein am Rechner, Termine und Aufgaben nebeneinander. |
|
||
| **Vivaldi** | Marcs Browser bringt einen CalDAV-Kalender mit. Wie eigenwillig er ist, wissen wir noch nicht. |
|
||
| **HoneyOS** | braucht **kein** CalDAV: Kachel und Leiste fragen Aethers eigene JSON-Schnittstelle. CalDAV ist für fremde Programme. |
|
||
|
||
## Standards und Vorbild
|
||
|
||
RFC 5545 das .ics-Format
|
||
RFC 4791 CalDAV (PUT, REPORT, calendar-query)
|
||
RFC 6764 /.well-known/caldav – damit reicht dem Client der Hostname
|
||
RFC 5397 current-user-principal: „wer bin ich, wo liegen meine Kalender"
|
||
RFC 6578 sync-collection: nur Änderungen holen
|
||
RFC 6352 CardDAV, falls Kontakte dazukommen
|
||
|
||
Wo der Standard Spielraum lässt, halten wir es wie **SabreDAV**: Nextcloud und
|
||
Baïkal stehen beide darauf, das ist also das Verhalten, an das die Clients
|
||
gewöhnt sind. Radicale ist zum Nachlesen gut – kurzer Code –, aber es ist nicht
|
||
die Fassung, gegen die getestet wurde.
|
||
|
||
## Kein Plugin – aber ausklinkbar gebaut
|
||
|
||
Der Projektplan legt es selbst fest (Abschnitt 8): *„ERST sauberer Core.
|
||
Extension-Points erst definieren, wenn der Core stabil ist. Kernfeatures
|
||
bleiben erstklassig, nicht als Plugin."* Ein Plugin-System zu bauen, um darauf
|
||
den Kalender zu setzen, drehte diese Reihenfolge um – und ein Kalender wäre
|
||
sein erster und einziger Nutzer, also entstünde die Schnittstelle nach genau
|
||
einem Fall.
|
||
|
||
Stattdessen so gebaut, dass er später herauszulösen ist:
|
||
|
||
- eigenes Paket `internal/calendar`, eigene Migrationen, eigene Routen
|
||
- angeschlossen in **einer** Zeile in `main.go`
|
||
- greift nach außen nur auf `auth` und `acl` zu, nicht in andere Pakete hinein
|
||
- abschaltbar: kein Kalender aktiv → keine Routen, kein Menüpunkt
|
||
|
||
Damit ist er der natürliche erste Kandidat, wenn es das Plugin-System einmal
|
||
gibt – ohne dass wir es vorher erfinden.
|
||
|
||
### Die Grenzen, an denen sich das entscheidet
|
||
|
||
Kein System, sechs Regeln:
|
||
|
||
1. **Niemand importiert `internal/calendar`.** Kein `files`, kein `sharing`,
|
||
kein `admin`. Der Kalender ist Blatt im Baum, nie Ast.
|
||
2. **Der Kalender importiert nur `auth`, `acl` und `pkg/…`** – dieselben
|
||
Hilfsmittel wie jeder andere Bereich, aber keine fremde Fachlogik.
|
||
3. **Alles unter einem Präfix**: `/api/v1/calendar/…` für die Oberfläche,
|
||
`/caldav/…` für die Geräte. Keine Route irgendwo dazwischen.
|
||
4. **Eigene Tabellen, erkennbar benannt** (`cal_calendars`, `cal_objects`,
|
||
`cal_shares`, `cal_view`). Keine Spalte in einer bestehenden Tabelle.
|
||
5. **Abschaltbar**: aus heißt keine Routen, kein Menüpunkt, keine Migration,
|
||
die etwas anderes anfasst.
|
||
6. **Keine Änderung an bestehenden Paketen**, außer der einen Zeile in
|
||
`main.go` und dem Eintrag in der Ansichtsliste des Frontends.
|
||
|
||
Regel 1 lässt sich festhalten statt hoffen: ein kleiner Test, der die Importe
|
||
durchgeht und fehlschlägt, sobald ein anderes Paket den Kalender zieht. Damit
|
||
merkt man die Grenzüberschreitung beim Bauen und nicht in zwei Jahren beim
|
||
Herauslösen.
|
||
|
||
**Migrationen** bleiben in der fortlaufenden Nummerierung des Kerns
|
||
(`000021_calendar…`). Eine zweite Versionstabelle wäre sauberer für ein echtes
|
||
Plugin, aber für eine persönliche Cloud ist sie Aufwand ohne Gegenwert – und
|
||
die Dateinamen sagen ohnehin, wozu sie gehören.
|
||
|
||
## Teamkalender über Gruppen
|
||
|
||
Aether kennt bereits Gruppen samt Gruppenordnern, und `acl.Access` trägt einen
|
||
**Prinzipal** statt eines Eigentümers – ein Konto *oder* eine Gruppe
|
||
(`IsGroup`). Genau das nehmen wir:
|
||
|
||
| Zweck | Weg |
|
||
|---|---|
|
||
| **Teamkalender** | Kalender gehört einer Gruppe. Wer in der Gruppe ist, hat ihn – ohne einzelne Freigaben. Angelegt in den Admin-Optionen, wie Gruppenordner. |
|
||
| **Persönlich teilen** | Freigabe an ein einzelnes Konto, mit Recht `read` oder `write`. In der Oberfläche ein Schalter „darf auch schreiben". |
|
||
|
||
Beides läuft über dieselbe Prüfung wie bei Dateien: `resolveCalendar` nach dem
|
||
Vorbild von `files.resolveFile`. Kein zweites Rechtekonzept.
|
||
|
||
## Oberfläche
|
||
|
||
**Mehrere Kalender gleichzeitig** – das ist der Normalfall, nicht die Ausnahme.
|
||
Die Ansicht legt sie übereinander; jeder Termin trägt die Farbe seines
|
||
Kalenders.
|
||
|
||
**Sichtbarkeit mit einem Klick**, wie in Google Kalender und der Samsung-App:
|
||
runder, in der Kalenderfarbe gefüllter Haken in der Liste. Klick = an, Klick =
|
||
aus. **Serverseitig je Nutzer gespeichert**, damit es am Rechner und am Handy
|
||
gleich aussieht – dafür eine Tabelle:
|
||
|
||
kalender_ansicht nutzer_id, kalender_id, sichtbar, reihenfolge, farbe_eigen
|
||
|
||
Das `farbe_eigen` ist geschenkt, wenn die Zeile ohnehin existiert: Ein geteilter
|
||
Kalender darf bei dir grün und beim anderen blau sein – so macht es Google auch.
|
||
|
||
**Wichtig:** Diese Einstellung gilt für *unsere* Oberfläche. Was das Telefon
|
||
anzeigt, entscheiden DAVx5 und Samsung Kalender selbst; darauf haben wir keinen
|
||
Zugriff.
|
||
|
||
## Farben
|
||
|
||
Zwei verschiedene Dinge, die man leicht verwechselt:
|
||
|
||
- **Farbe des Kalenders** – das, was du in der Samsung-App siehst. Im Protokoll
|
||
`calendar-color` aus Apples Namensraum. Steht in keinem RFC, ist aber
|
||
de-facto-Standard: Nextcloud, Thunderbird und DAVx5 können es. **Geht.**
|
||
- **Farbe eines einzelnen Termins** – `COLOR` aus RFC 7986. In unserer eigenen
|
||
Oberfläche jederzeit möglich; wie weit DAVx5 und Samsung Kalender das
|
||
weiterreichen, ist offen und muss am Gerät geprüft werden. Geschrieben wird
|
||
sie als CSS3-**Name** (`tomato`), nicht als Hex-Wert – so verlangt es RFC
|
||
7986, und so liest DAVx5 sie. Der genaue Wert steht zusätzlich in
|
||
`X-AETHER-COLOR`, damit die eigene Palette verlustfrei zurückkommt.
|
||
|
||
**Am Gerät geprüft (06.09.2026):** Die Kalenderfarbe kommt an – aber erst,
|
||
nachdem in DAVx5 einmal *Kalenderliste aktualisieren* angetippt wurde. Beim
|
||
normalen Abgleich fragt DAVx5 `calendar-color` gar nicht ab; ein Mitschnitt
|
||
der Anfragen hat das gezeigt. Wer das nicht weiß, sucht den Fehler auf der
|
||
Serverseite, wo keiner ist.
|
||
|
||
**Emojis im Kalendernamen** sind kein Protokollthema – der Name ist Text.
|
||
„🏠 Haushalt" kommt überall an.
|
||
|
||
## Migrationen: geprüft, was bei einem Fehler wirklich passiert
|
||
|
||
Nicht angenommen, sondern nachgestellt: eine absichtlich kaputte Migration in
|
||
einer Wegwerf-Datenbank laufen lassen. Ergebnis, damit hier niemand rät:
|
||
|
||
1/u ok
|
||
error: relation "cal_calendars" already exists
|
||
→ schema_migrations steht auf version=2, dirty=true
|
||
→ jeder weitere Start: "Dirty database version 2. Fix and force version."
|
||
|
||
**Der zweite Teil ist der eigentliche Befund:** Die eine kaputte Datei bleibt
|
||
nicht lokal begrenzt. `main.go` bricht bei einem Migrationsfehler mit
|
||
`log.Fatal` ab – das gilt für **jede** Migration, nicht nur für die des
|
||
Kalenders, und stand schon vor diesem Vorhaben so im Code. Eine hängende
|
||
Migration verhindert also den Start der ganzen Cloud, bis jemand von Hand
|
||
`migrate force <version>` ausführt. Genau das darf einem „Extra" nicht
|
||
passieren – deshalb drei Sicherungen, bevor die erste Kalendermigration
|
||
überhaupt geschrieben wird:
|
||
|
||
1. **Nur `CREATE TABLE IF NOT EXISTS`, nie `ALTER` an Fremdem.** Idempotent:
|
||
Selbst wenn eine Datei mitten in einem Mehr-Tabellen-Schritt abbricht, ist
|
||
ein erneuter Lauf nach `migrate force` ein Nichts-tun statt eines zweiten
|
||
Fehlers.
|
||
2. **Erst auf der Dev-Instanz, hoch und runter**, mit denselben 40
|
||
bestehenden Migrationen davor – nicht in einer leeren Testdatenbank wie
|
||
beim Nachstellen hier.
|
||
3. **Ein `pg_dump` vor dem ersten produktiven Ausrollen.** Sekundensache,
|
||
und die Rückversicherung für den Fall, den die ersten beiden Punkte
|
||
eigentlich schon ausschließen sollen.
|
||
|
||
Und für den Fall, dass es trotzdem einmal passiert – nicht nur beim
|
||
Kalender, bei jeder künftigen Migration –, gehört die Notfallzeile in
|
||
`TESTING.md`:
|
||
|
||
docker exec aether-db psql -U aether -d aether \
|
||
-c "UPDATE schema_migrations SET dirty=false;"
|
||
|
||
Kein Datenverlust dabei: Die fehlgeschlagene Migration lief in einer
|
||
Transaktion und wurde zurückgerollt (im Test oben existierte `cal_calendars`
|
||
danach nicht) – die Zeile hebt nur die Sperre auf, nichts sonst.
|
||
|
||
## Die Notausfahrt
|
||
|
||
Ein Kalender, der lautlos einen Termin verschluckt, ist schlimmer als einer,
|
||
der ausfällt. Deshalb:
|
||
|
||
- **Export als `.ics` je Kalender ist Teil von Phase 2, nicht später.** Solange
|
||
du deine Termine jederzeit herausbekommst, ist keine Entscheidung endgültig.
|
||
- Auf Wunsch zusätzlich als Verzeichnis mit einer Datei je Termin – das ist der
|
||
Aufbau, den Radicale liest. Umziehen wäre dann ein Kopiervorgang.
|
||
- Tests mit echten Anfragen, die DAVx5 stellt (mitgeschnitten, nicht erfunden),
|
||
und eine Prüfliste in `TESTING.md` fürs Telefon.
|
||
|
||
## Offene Fragen
|
||
|
||
1. Wie viele Kalender – einer, oder getrennt nach Privat/Arbeit/Geburtstage?
|
||
2. Aufgaben mit dazu (VTODO), oder reine Termine?
|
||
3. Der Kalender in der HoneyOS-Leiste: nur der nächste Termin, oder eine
|
||
eigene Kachel?
|
||
4. Kontakte: ernsthaft geplant oder erst mal offenlassen? Es ändert nichts an
|
||
Phase 1–3, nur an der Benennung der Pakete.
|