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:
1 parent
79a7cc6a3d
commit
0c9cc69493
7 files changed
+608
-23
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user