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

+111
View File
@@ -0,0 +1,111 @@
package calendar
import (
"fmt"
"strconv"
"strings"
)
// Terminfarben und die Synchronisierung.
//
// RFC 7986 schreibt für COLOR einen **CSS3-Farbnamen** vor ("tomato"), keinen
// Hex-Wert. Genau daran hängt, ob die Farbe eines Termins auf dem Telefon
// ankommt: DAVx5 liest die Eigenschaft über eine Namenstabelle und wirft
// alles weg, was es dort nicht findet – ein "#ea4335" landet also nirgends,
// und im Samsung-Kalender bliebe der Termin farblos.
//
// Deshalb zwei Eigenschaften nebeneinander:
//
// COLOR:tomato – der Standard, damit fremde Clients es verstehen
// X-AETHER-COLOR:#ea4335 – der genaue Wert, damit *hier* die Palette stimmt
//
// Beim Lesen gewinnt X-AETHER-COLOR, wenn vorhanden; sonst wird der Name in
// Hex zurückübersetzt. So geht in beide Richtungen nichts verloren: Ein
// Termin, den das Telefon einfärbt, kommt hier mit seiner Farbe an, und
// unsere Palette bleibt auf dem Telefon erkennbar – wenn auch auf den
// nächstgelegenen benannten Ton gerundet.
const (
propColor = "COLOR"
propColorX = "X-AETHER-COLOR"
)
// css3Colors ist eine Auswahl aus der CSS3-Namensliste – über den Farbkreis
// verteilt, dazu die Töne, die Kalender-Clients üblicherweise selbst
// vergeben. Nicht die vollständige Liste (147 Namen): Für "nächstgelegener
// Ton" bringt jeder weitere Zwischenwert kaum etwas, und die Liste will
// gepflegt werden.
var css3Colors = map[string]string{
"black": "#000000", "dimgray": "#696969", "gray": "#808080",
"darkgray": "#a9a9a9", "silver": "#c0c0c0", "white": "#ffffff",
"maroon": "#800000", "brown": "#a52a2a", "firebrick": "#b22222",
"crimson": "#dc143c", "red": "#ff0000", "tomato": "#ff6347",
"coral": "#ff7f50", "orangered": "#ff4500", "darkorange": "#ff8c00",
"orange": "#ffa500", "goldenrod": "#daa520", "gold": "#ffd700",
"yellow": "#ffff00", "olive": "#808000", "yellowgreen": "#9acd32",
"darkolivegreen": "#556b2f", "lawngreen": "#7cfc00", "limegreen": "#32cd32",
"green": "#008000", "darkgreen": "#006400", "seagreen": "#2e8b57",
"mediumseagreen": "#3cb371", "teal": "#008080", "darkcyan": "#008b8b",
"lightseagreen": "#20b2aa", "turquoise": "#40e0d0", "cyan": "#00ffff",
"deepskyblue": "#00bfff", "steelblue": "#4682b4", "cornflowerblue": "#6495ed",
"royalblue": "#4169e1", "dodgerblue": "#1e90ff", "blue": "#0000ff",
"mediumblue": "#0000cd", "navy": "#000080", "slateblue": "#6a5acd",
"mediumpurple": "#9370db", "blueviolet": "#8a2be2", "darkviolet": "#9400d3",
"purple": "#800080", "orchid": "#da70d6", "magenta": "#ff00ff",
"mediumvioletred": "#c71585", "deeppink": "#ff1493", "hotpink": "#ff69b4",
"pink": "#ffc0cb", "slategray": "#708090", "lightslategray": "#778899",
"tan": "#d2b48c", "peru": "#cd853f", "sienna": "#a0522d",
}
func parseHex(s string) (r, g, b int, ok bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(s), "#"))
if len(s) == 3 { // Kurzform #abc
s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]})
}
if len(s) == 8 { // mit Alpha – der Kanal interessiert hier nicht
s = s[:6]
}
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v>>16) & 0xff, int(v>>8) & 0xff, int(v) & 0xff, true
}
// nearestCSS3 findet den nächstgelegenen benannten Ton. Der Abstand wird im
// RGB-Würfel gemessen – grob, aber für "welcher Name kommt dem am nächsten"
// vollkommen ausreichend, und ohne Farbmetrik-Bibliothek im Gepäck.
func nearestCSS3(hex string) string {
r, g, b, ok := parseHex(hex)
if !ok {
return ""
}
best, bestDist := "", 1<<30
for name, ref := range css3Colors {
rr, rg, rb, _ := parseHex(ref)
d := (r-rr)*(r-rr) + (g-rg)*(g-rg) + (b-rb)*(b-rb)
// Bei gleichem Abstand der alphabetisch erste Name – sonst wechselt
// die Ausgabe zwischen zwei Läufen, weil map-Reihenfolge zufällig ist.
if d < bestDist || (d == bestDist && name < best) {
best, bestDist = name, d
}
}
return best
}
// css3ToHex übersetzt zurück. Unbekannter Name: leer – der Termin erbt dann
// die Kalenderfarbe, was allemal besser ist als eine geratene.
func css3ToHex(name string) string {
return css3Colors[strings.ToLower(strings.TrimSpace(name))]
}
// normalizeHex bringt eine Farbe auf die Schreibweise "#rrggbb".
func normalizeHex(s string) string {
r, g, b, ok := parseHex(s)
if !ok {
return ""
}
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
+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