feat(calendar): Wiederholungsmuster, Terminfarbe synchronisierbar, Selbsttest

Terminfarbe: Sie kam auf dem Telefon nie an. RFC 7986 schreibt für COLOR
einen CSS3-*Namen* vor, wir schrieben Hex – DAVx5 liest die Eigenschaft
über eine Namenstabelle und verwirft alles andere, also blieb der Termin
im Samsung-Kalender farblos. Jetzt beides nebeneinander:

    COLOR:tomato            für fremde Clients (damit die Farbe ankommt)
    X-AETHER-COLOR:#ea4335  für die eigene Palette (damit sie genau bleibt)

Beim Lesen gewinnt der genaue Wert; ein von außen eingefärbter Termin
wird über die Namenstabelle übersetzt. Nächstgelegener Ton per Abstand im
RGB-Würfel (color.go).

Wiederholungen können jetzt mehr als "alle N":

- wöchentlich an ausgewählten Wochentagen (Mo–So, Mehrfachauswahl). Der
  Wochentag des Starts bleibt fest angehakt – eine Reihe, die ihren
  eigenen ersten Termin auslässt, ist keine.
- monatlich und jährlich wahlweise am festen Kalendertag (am 4.) oder am
  n-ten Wochentag (am 1. Freitag). Beide Angaben leitet der Server aus
  dem Startdatum ab, statt sie getrennt entgegenzunehmen – so kann die
  Regel gar nicht erst auf einen anderen Tag zeigen als der Termin.
- Der fünfte Wochentag eines Monats wird bewusst zu "letzter": Einen
  fünften Freitag hat nicht jeder Monat, BYDAY=5FR ließe die Reihe sonst
  in etwa jedem zweiten Monat stillschweigend ausfallen.

Regeln, die das Formular weiterhin nicht abbildet, bleiben lesbar, aber
nicht bearbeitbar (409) – unverändert.

Dazu scripts/kalender-selbsttest.py, 19 Prüfungen gegen die laufende
Instanz, in TESTING.md verlinkt. Alle grün, unter anderem:

    am 1. Freitag monatlich   -> 04.09. 02.10. 06.11. 04.12.
    am 1. Freitag im September -> 2026-09-04, 2027-09-03
    Mo+Fr wöchentlich          -> 07./11./14./18./21./25.09.
    ganztägig am 3.            -> bleibt der 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 01:16:06 +02:00
1 parent 79a7cc6a3d
commit 0c9cc69493
7 files changed
+608 -23

No files matched your search

+166 -21
View File
@@ -15,14 +15,6 @@ import (
"aether/pkg/response"
)
// propColor ist die Farbe eines einzelnen Termins (RFC 7986). Der Standard
// sieht dort einen CSS3-*Namen* vor, wir schreiben einen Hex-Wert – so wie
// es auch Nextcloud und Thunderbird tun, weil eine Palette aus benannten
// Farben in einer Oberfläche niemand pflegen will. Clients, die die
// Eigenschaft nicht verstehen, überspringen sie; niemand zeigt deswegen
// etwas Falsches an.
const propColor = "COLOR"
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
// sonst allein genug, um eine Monatsansicht lahmzulegen.
@@ -40,6 +32,58 @@ type RecurrenceDTO struct {
Interval int `json:"interval"` // >= 1
Count int `json:"count,omitempty"`
Until *time.Time `json:"until,omitempty"`
// WEEKLY: an welchen Wochentagen (MO…SU). Leer = Wochentag des Starts.
Byday []string `json:"byday,omitempty"`
// MONTHLY/YEARLY: "DAY" wiederholt am festen Kalendertag (am 4.),
// "WEEKDAY" am n-ten Wochentag (am 1. Freitag). Leer zählt als DAY.
Mode string `json:"mode,omitempty"`
// Nur bei Mode=WEEKDAY und nur zum **Anzeigen**: der wievielte (−1 =
// letzter) und welcher Wochentag. Beim Schreiben rechnet der Server
// beides aus dem Startdatum aus – zwei Quellen für dieselbe Angabe wären
// eine zu viel, und sie liefen irgendwann auseinander.
Nth int `json:"nth,omitempty"`
Weekday string `json:"weekday,omitempty"`
}
var weekdayCodes = []string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
// weekdayIndex: 0 = Montag. Gos time.Weekday fängt beim Sonntag an, rrule-go
// beim Montag – diese eine Zeile ist der Übersetzer dazwischen.
func weekdayIndex(t time.Time) int { return (int(t.Weekday()) + 6) % 7 }
func weekdayCode(t time.Time) string { return weekdayCodes[weekdayIndex(t)] }
func rruleWeekday(code string) (rrule.Weekday, bool) {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "MO":
return rrule.MO, true
case "TU":
return rrule.TU, true
case "WE":
return rrule.WE, true
case "TH":
return rrule.TH, true
case "FR":
return rrule.FR, true
case "SA":
return rrule.SA, true
case "SU":
return rrule.SU, true
}
return rrule.MO, false
}
// nthOfMonth: der wievielte Wochentag seiner Art der Tag im Monat ist.
//
// Der fünfte wird zu −1 ("letzter"), und das ist kein Schönheitsfehler: Einen
// fünften Freitag hat nicht jeder Monat. FREQ=MONTHLY;BYDAY=5FR ließe die
// Reihe in etwa der Hälfte aller Monate stillschweigend ausfallen – ein
// Fehler, den man erst Wochen später bemerkt.
func nthOfMonth(t time.Time) int {
if n := (t.Day()-1)/7 + 1; n < 5 {
return n
}
return -1
}
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
@@ -115,24 +159,66 @@ func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
if !ok {
return nil // stündlich und feiner bietet die Oberfläche nicht an
}
if len(opt.Bysetpos) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 ||
len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 ||
len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 {
// Diese Bestandteile kennt das Formular in keiner Betriebsart.
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
len(opt.Byeaster) > 0 {
return nil
}
// BYDAY mit genau dem Wochentag des Starts ist die Schreibweise vieler
// Clients für „jede Woche" und ändert nichts an der Bedeutung. Alles
// andere (mehrere Tage, Wochentag ≠ Start) ist eine echte Erweiterung.
if len(opt.Byweekday) > 0 {
if freq != "WEEKLY" || len(opt.Byweekday) != 1 {
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
switch freq {
case "DAILY":
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
return nil
}
if opt.Byweekday[0].Day() != int(start.Weekday()+6)%7 {
case "WEEKLY":
if len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
return nil
}
for _, wd := range opt.Byweekday {
if wd.N() != 0 {
return nil // "jeder 2. Dienstag" ist keine Wochenregel
}
dto.Byday = append(dto.Byday, weekdayCodes[wd.Day()])
}
if len(dto.Byday) == 0 {
dto.Byday = []string{weekdayCode(start)}
}
case "MONTHLY", "YEARLY":
// Bei jährlich darf der Monat genannt sein – aber nur der des Starts.
if freq == "YEARLY" {
if len(opt.Bymonth) > 1 || (len(opt.Bymonth) == 1 && opt.Bymonth[0] != int(start.Month())) {
return nil
}
} else if len(opt.Bymonth) > 0 {
return nil
}
switch {
case len(opt.Byweekday) == 1 && opt.Byweekday[0].N() != 0:
// „am n-ten Wochentag". Nur bearbeitbar, wenn die Regel auch
// wirklich auf den Starttag zeigt – sonst zeigte das Formular
// etwas anderes an, als gespeichert ist.
wd := opt.Byweekday[0]
if len(opt.Bymonthday) > 0 || wd.Day() != weekdayIndex(start) || wd.N() != nthOfMonth(start) {
return nil
}
dto.Mode, dto.Nth, dto.Weekday = "WEEKDAY", wd.N(), weekdayCode(start)
case len(opt.Byweekday) == 0:
// „am festen Kalendertag" – ausgeschrieben oder implizit.
if len(opt.Bymonthday) > 1 || (len(opt.Bymonthday) == 1 && opt.Bymonthday[0] != start.Day()) {
return nil
}
dto.Mode = "DAY"
default:
return nil
}
}
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
if dto.Interval < 1 {
dto.Interval = 1
}
@@ -157,7 +243,22 @@ func eventFromObject(o *Object) (*EventDTO, error) {
summary, _ := ev.Props.Text(ical.PropSummary)
description, _ := ev.Props.Text(ical.PropDescription)
location, _ := ev.Props.Text(ical.PropLocation)
color, _ := ev.Props.Text(propColor)
// Farbe: unser genauer Wert schlägt den benannten Ton, siehe color.go.
// Ein Termin, den ein anderer Client eingefärbt hat, bringt nur COLOR
// mit – der wird dann übersetzt.
color, _ := ev.Props.Text(propColorX)
if color == "" {
if name, _ := ev.Props.Text(propColor); name != "" {
if strings.HasPrefix(strings.TrimSpace(name), "#") {
color = normalizeHex(name)
} else {
color = css3ToHex(name)
}
}
} else {
color = normalizeHex(color)
}
dto := &EventDTO{
UID: o.UID, Summary: summary, Description: description, Location: location,
@@ -342,8 +443,18 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
if body.Location != "" {
ev.Props.SetText(ical.PropLocation, body.Location)
}
if body.Color != "" {
ev.Props.SetText(propColor, body.Color)
if hex := normalizeHex(body.Color); hex != "" {
// Beides schreiben: den benannten Ton für fremde Clients (DAVx5
// überträgt daraus die Terminfarbe ins Telefon), den genauen Wert
// für die eigene Palette. Siehe color.go.
if name := nearestCSS3(hex); name != "" {
ev.Props.SetText(propColor, name)
}
// Ohne SetText geschrieben: Das hängte an die unbekannte
// X-Eigenschaft ein überflüssiges VALUE=TEXT an.
p := ical.NewProp(propColorX)
p.Value = hex
ev.Props.Set(p)
}
if body.AllDay {
@@ -396,6 +507,40 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
interval = 1
}
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
// Der Start ist die Bezugsgröße für alle BY-Angaben: „am 4." und „am 1.
// Freitag" leiten sich beide aus ihm ab, statt getrennt mitgeschickt zu
// werden. So kann die Regel gar nicht erst auf einen anderen Tag zeigen
// als der Termin selbst.
start := body.Start.UTC()
switch strings.ToUpper(rec.Freq) {
case "WEEKLY":
codes := rec.Byday
if len(codes) == 0 {
codes = []string{weekdayCode(start)}
}
seen := map[string]bool{}
for _, c := range codes {
wd, ok := rruleWeekday(c)
if !ok || seen[strings.ToUpper(c)] {
continue
}
seen[strings.ToUpper(c)] = true
opt.Byweekday = append(opt.Byweekday, wd)
}
case "MONTHLY", "YEARLY":
if strings.EqualFold(rec.Mode, "WEEKDAY") {
wd, _ := rruleWeekday(weekdayCode(start))
opt.Byweekday = []rrule.Weekday{wd.Nth(nthOfMonth(start))}
} else {
opt.Bymonthday = []int{start.Day()}
}
if strings.EqualFold(rec.Freq, "YEARLY") {
opt.Bymonth = []int{int(start.Month())}
}
}
if rec.Until != nil {
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte