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
+17
@@ -9,6 +9,23 @@ Manuelle Test-Checkliste für die fertigen Phasen. Abhaken beim Durchtesten.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Kalender: Selbsttest per Skript
|
||||||
|
|
||||||
|
Die Kalender-Schnittstelle hat einen automatischen Selbsttest – schneller und
|
||||||
|
gründlicher als Klicken, und er prüft genau die Stellen, an denen schon
|
||||||
|
einmal etwas schiefging:
|
||||||
|
|
||||||
|
python3 scripts/kalender-selbsttest.py
|
||||||
|
|
||||||
|
19 Prüfungen: Ganztags-Datum (der 3. muss der 3. bleiben – der Fehler, bei dem
|
||||||
|
ein Termin östlich von UTC einen Tag zurücksprang), mehrtägige Termine,
|
||||||
|
Terminfarbe in beiden Schreibweisen, alle Wiederholungsmuster (wöchentlich an
|
||||||
|
bestimmten Tagen, monatlich/jährlich am festen Tag bzw. am n-ten Wochentag),
|
||||||
|
Bearbeiten einer Reihe, und dass das Zeitfenster wirklich begrenzt.
|
||||||
|
|
||||||
|
**Legt Termine an und löscht am Ende alle Termine im ersten Kalender des
|
||||||
|
Kontos.** Also nur gegen eine Entwicklungsinstanz laufen lassen.
|
||||||
|
|
||||||
## Notfall: Migration hängt fest ("Dirty database version")
|
## Notfall: Migration hängt fest ("Dirty database version")
|
||||||
|
|
||||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -15,14 +15,6 @@ import (
|
|||||||
"aether/pkg/response"
|
"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
|
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
|
||||||
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
||||||
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
||||||
@@ -40,6 +32,58 @@ type RecurrenceDTO struct {
|
|||||||
Interval int `json:"interval"` // >= 1
|
Interval int `json:"interval"` // >= 1
|
||||||
Count int `json:"count,omitempty"`
|
Count int `json:"count,omitempty"`
|
||||||
Until *time.Time `json:"until,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.
|
// 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 {
|
if !ok {
|
||||||
return nil // stündlich und feiner bietet die Oberfläche nicht an
|
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 ||
|
// Diese Bestandteile kennt das Formular in keiner Betriebsart.
|
||||||
len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 ||
|
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
||||||
len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 {
|
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
|
||||||
|
len(opt.Byeaster) > 0 {
|
||||||
return nil
|
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
|
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||||||
// andere (mehrere Tage, Wochentag ≠ Start) ist eine echte Erweiterung.
|
|
||||||
if len(opt.Byweekday) > 0 {
|
switch freq {
|
||||||
if freq != "WEEKLY" || len(opt.Byweekday) != 1 {
|
case "DAILY":
|
||||||
|
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||||||
return nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
|
||||||
if dto.Interval < 1 {
|
if dto.Interval < 1 {
|
||||||
dto.Interval = 1
|
dto.Interval = 1
|
||||||
}
|
}
|
||||||
@@ -157,7 +243,22 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
|||||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||||
description, _ := ev.Props.Text(ical.PropDescription)
|
description, _ := ev.Props.Text(ical.PropDescription)
|
||||||
location, _ := ev.Props.Text(ical.PropLocation)
|
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{
|
dto := &EventDTO{
|
||||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||||
@@ -342,8 +443,18 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
|||||||
if body.Location != "" {
|
if body.Location != "" {
|
||||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||||
}
|
}
|
||||||
if body.Color != "" {
|
if hex := normalizeHex(body.Color); hex != "" {
|
||||||
ev.Props.SetText(propColor, body.Color)
|
// 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 {
|
if body.AllDay {
|
||||||
@@ -396,6 +507,40 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
|||||||
interval = 1
|
interval = 1
|
||||||
}
|
}
|
||||||
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
|
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 {
|
if rec.Until != nil {
|
||||||
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
||||||
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
||||||
|
|||||||
@@ -906,12 +906,22 @@ export interface CalendarItem {
|
|||||||
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
|
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
|
||||||
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
|
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
|
||||||
* sind dann nur lesbar (siehe events_api.go). */
|
* sind dann nur lesbar (siehe events_api.go). */
|
||||||
|
export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
|
||||||
|
|
||||||
export interface Recurrence {
|
export interface Recurrence {
|
||||||
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
||||||
interval: number
|
interval: number
|
||||||
count?: number
|
count?: number
|
||||||
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
|
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
|
||||||
until?: string
|
until?: string
|
||||||
|
/** Nur WEEKLY: an welchen Wochentagen. Leer = Wochentag des Starts. */
|
||||||
|
byday?: WeekdayCode[]
|
||||||
|
/** Nur MONTHLY/YEARLY: am festen Kalendertag (am 4.) oder am n-ten
|
||||||
|
* Wochentag (am 1. Freitag). */
|
||||||
|
mode?: 'DAY' | 'WEEKDAY'
|
||||||
|
/** Nur zum Anzeigen – der Server rechnet beides aus dem Startdatum aus. */
|
||||||
|
nth?: number
|
||||||
|
weekday?: WeekdayCode
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
|
||||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||||
import type { CalendarEvent, CalendarItem, EventInput, Recurrence } from '../../api/client'
|
import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode } from '../../api/client'
|
||||||
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
|
import {
|
||||||
|
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
|
||||||
|
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
|
||||||
|
} from './dates'
|
||||||
import { ColorSwatches, Toggle } from './controls'
|
import { ColorSwatches, Toggle } from './controls'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +30,25 @@ const freqLabels: Record<FreqChoice, string> = {
|
|||||||
YEARLY: 'Jährlich',
|
YEARLY: 'Jährlich',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||||
|
const monthNames = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie
|
||||||
|
* man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */
|
||||||
|
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
|
||||||
|
const n = nthOfMonth(start)
|
||||||
|
const nth = n === -1 ? 'letzten' : `${n}.`
|
||||||
|
const wd = weekdayLong[(start.getDay() + 6) % 7]
|
||||||
|
const month = monthNames[start.getMonth()]
|
||||||
|
return yearly
|
||||||
|
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
|
||||||
|
: { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` }
|
||||||
|
}
|
||||||
|
|
||||||
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
|
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
|
||||||
const overlay = useOverlayDismiss(onCancel)
|
const overlay = useOverlayDismiss(onCancel)
|
||||||
const titleRef = useRef<HTMLInputElement>(null)
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -61,6 +83,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
const [count, setCount] = useState(String(rec?.count ?? 10))
|
const [count, setCount] = useState(String(rec?.count ?? 10))
|
||||||
const [until, setUntil] = useState(
|
const [until, setUntil] = useState(
|
||||||
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90)))
|
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90)))
|
||||||
|
const [byday, setByday] = useState<WeekdayCode[]>(rec?.byday ?? [])
|
||||||
|
const [monthMode, setMonthMode] = useState<'DAY' | 'WEEKDAY'>(rec?.mode === 'WEEKDAY' ? 'WEEKDAY' : 'DAY')
|
||||||
|
|
||||||
|
// Bezugstag für alles, was sich aus dem Start ableitet („am 1. Freitag").
|
||||||
|
// Mittags, damit ein Sommerzeitsprung das Datum nicht kippt.
|
||||||
|
const startDay = dateFromInput(startDate)
|
||||||
|
const startWeekday = weekdayCodeOf(startDay)
|
||||||
|
// Der Wochentag des Starts ist immer dabei und lässt sich nicht abwählen –
|
||||||
|
// sonst zeigte die Reihe nicht mehr auf ihren eigenen ersten Termin.
|
||||||
|
const activeDays: WeekdayCode[] = Array.from(new Set([...byday, startWeekday]))
|
||||||
|
const modeLabels = monthlyModeLabels(startDay, freq === 'YEARLY')
|
||||||
|
|
||||||
useEffect(() => { titleRef.current?.focus() }, [])
|
useEffect(() => { titleRef.current?.focus() }, [])
|
||||||
|
|
||||||
@@ -89,9 +122,16 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) }
|
const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) }
|
||||||
if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
|
if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
|
||||||
if (endChoice === 'UNTIL') r.until = allDayISO(until)
|
if (endChoice === 'UNTIL') r.until = allDayISO(until)
|
||||||
|
if (freq === 'WEEKLY') r.byday = activeDays
|
||||||
|
if (freq === 'MONTHLY' || freq === 'YEARLY') r.mode = monthMode
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleDay(code: WeekdayCode) {
|
||||||
|
if (code === startWeekday) return
|
||||||
|
setByday(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code])
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (readOnly || !summary.trim()) return
|
if (readOnly || !summary.trim()) return
|
||||||
@@ -234,6 +274,51 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wöchentlich: an welchen Tagen. Der Starttag ist fest an –
|
||||||
|
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
|
||||||
|
{freq === 'WEEKLY' && (
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={labelStyle}>An diesen Tagen</label>
|
||||||
|
<div style={{ display: 'flex', gap: 5 }}>
|
||||||
|
{weekdayCodes.map((code, i) => {
|
||||||
|
const on = activeDays.includes(code)
|
||||||
|
const fixed = code === startWeekday
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={code} type="button" disabled={readOnly || fixed}
|
||||||
|
onClick={() => toggleDay(code)}
|
||||||
|
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: on ? 'var(--color-primary)' : 'transparent',
|
||||||
|
color: on ? '#fff' : 'var(--color-text-muted)',
|
||||||
|
cursor: readOnly || fixed ? 'default' : 'pointer',
|
||||||
|
opacity: fixed && !readOnly ? 0.85 : 1,
|
||||||
|
transition: 'background 0.12s, color 0.12s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{weekdayShort[i]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
|
||||||
|
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={labelStyle}>Wiederholt sich</label>
|
||||||
|
<select value={monthMode} disabled={readOnly}
|
||||||
|
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
|
||||||
|
style={inputStyle}>
|
||||||
|
<option value="DAY">{modeLabels.day}</option>
|
||||||
|
<option value="WEEKDAY">{modeLabels.weekday}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{freq !== 'NONE' && (
|
{freq !== 'NONE' && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<label style={labelStyle}>Endet</label>
|
<label style={labelStyle}>Endet</label>
|
||||||
|
|||||||
@@ -12,6 +12,37 @@
|
|||||||
|
|
||||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
export const weekdayCodes = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const
|
||||||
|
export type WeekdayCode = typeof weekdayCodes[number]
|
||||||
|
|
||||||
|
/** 0 = Montag. JavaScript fängt beim Sonntag an – diese Zeile ist der
|
||||||
|
* Übersetzer, und sie steht genau einmal. */
|
||||||
|
export function weekdayIndex(d: Date): number {
|
||||||
|
return (d.getDay() + 6) % 7
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekdayCodeOf(d: Date): WeekdayCode {
|
||||||
|
return weekdayCodes[weekdayIndex(d)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der wievielte Wochentag seiner Art der Tag im Monat ist – 1 bis 4, und
|
||||||
|
* −1 für „letzter".
|
||||||
|
*
|
||||||
|
* Der fünfte wird bewusst zu −1: Einen fünften Freitag hat nicht jeder
|
||||||
|
* Monat, eine Reihe darauf fiele in etwa jedem zweiten Monat still aus.
|
||||||
|
*/
|
||||||
|
export function nthOfMonth(d: Date): number {
|
||||||
|
const n = Math.floor((d.getDate() - 1) / 7) + 1
|
||||||
|
return n < 5 ? n : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Tag, den die Datumsfelder gerade zeigen – 12 Uhr mittags, damit
|
||||||
|
* Sommerzeitsprünge das Datum nicht kippen können. */
|
||||||
|
export function dateFromInput(value: string): Date {
|
||||||
|
return new Date(`${value}T12:00:00`)
|
||||||
|
}
|
||||||
|
|
||||||
/** Ortszeit → "YYYY-MM-DD" für <input type="date">. */
|
/** Ortszeit → "YYYY-MM-DD" für <input type="date">. */
|
||||||
export function toDateInput(d: Date): string {
|
export function toDateInput(d: Date): string {
|
||||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||||
|
|||||||
Executable
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Selbsttest der Kalender-Schnittstelle gegen eine laufende Instanz.
|
||||||
|
|
||||||
|
python3 scripts/kalender-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
|
||||||
|
|
||||||
|
Voreinstellung: http://localhost:8090, admin/admin123 (Entwicklungsumgebung).
|
||||||
|
|
||||||
|
Prüft die Stellen, an denen bereits einmal etwas schiefging – allen voran das
|
||||||
|
Ganztags-Datum, das in einer Zeitzone östlich von UTC einen Tag zurücksprang –
|
||||||
|
und die Wiederholungsmuster, deren Fehler man sonst erst Wochen später
|
||||||
|
bemerkt ("jeder 1. Freitag" darf nicht im Monat ohne fünften Freitag
|
||||||
|
ausfallen).
|
||||||
|
|
||||||
|
**Achtung: legt Termine im ersten Kalender des Kontos an** und löscht am Ende
|
||||||
|
*alle* Termine darin. Nicht gegen einen Kalender laufen lassen, in dem echte
|
||||||
|
Termine stehen.
|
||||||
|
"""
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090"
|
||||||
|
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
|
||||||
|
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
|
||||||
|
ok_count = 0
|
||||||
|
fail = []
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, body=None, headers=None, raw=False):
|
||||||
|
url = BASE + path
|
||||||
|
data = None
|
||||||
|
hdrs = dict(headers or {})
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
||||||
|
try:
|
||||||
|
with OPENER.open(req) as r:
|
||||||
|
payload = r.read().decode()
|
||||||
|
if raw:
|
||||||
|
return r.status, payload
|
||||||
|
return r.status, (json.loads(payload).get("data") if payload else None)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, None
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
global ok_count
|
||||||
|
if cond:
|
||||||
|
ok_count += 1
|
||||||
|
print(f" \033[32mok\033[0m {name}")
|
||||||
|
else:
|
||||||
|
fail.append(name)
|
||||||
|
print(f" \033[31mFEHL\033[0m {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
|
||||||
|
# ── Anmelden ────────────────────────────────────────────────────────────
|
||||||
|
st, data = api("POST", "/api/v1/auth/login",
|
||||||
|
{"username": USER, "password": PASSWORT})
|
||||||
|
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
|
||||||
|
CSRF = {"X-CSRF-Token": data["csrf_token"]}
|
||||||
|
|
||||||
|
st, cals = api("GET", "/api/v1/calendars")
|
||||||
|
CAL = cals[0]["id"]
|
||||||
|
EV = f"/api/v1/calendars/{CAL}/events"
|
||||||
|
print(f"Kalender: {cals[0]['name']}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def create(**body):
|
||||||
|
st, d = api("POST", EV, body, CSRF)
|
||||||
|
assert st == 201, f"Anlegen fehlgeschlagen ({st}): {body.get('summary')}"
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def events(frm, to):
|
||||||
|
st, d = api("GET", f"{EV}?from={frm}&to={to}")
|
||||||
|
return d or []
|
||||||
|
|
||||||
|
|
||||||
|
def starts(summary, frm="2026-01-01T00:00:00Z", to="2027-12-31T00:00:00Z"):
|
||||||
|
return [e["start"][:10] for e in events(frm, to) if e["summary"] == summary]
|
||||||
|
|
||||||
|
|
||||||
|
print("── Ganztägig: Datum darf nicht verrutschen ──")
|
||||||
|
e = create(summary="T ganztag", start="2026-09-03T00:00:00Z",
|
||||||
|
end="2026-09-03T00:00:00Z", all_day=True)
|
||||||
|
check("ein Tag bleibt der 3.", e["start"][:10] == "2026-09-03", e["start"])
|
||||||
|
check("Ende ist derselbe Tag", e["end"][:10] == "2026-09-03", e["end"])
|
||||||
|
|
||||||
|
e2 = create(summary="T mehrtags", start="2026-09-10T00:00:00Z",
|
||||||
|
end="2026-09-12T00:00:00Z", all_day=True)
|
||||||
|
check("mehrtägig 10.–12.", e2["start"][:10] == "2026-09-10" and e2["end"][:10] == "2026-09-12",
|
||||||
|
f'{e2["start"]}..{e2["end"]}')
|
||||||
|
|
||||||
|
print("\n── Uhrzeit: unverändert durchreichen ──")
|
||||||
|
e3 = create(summary="T uhrzeit", start="2026-09-15T12:30:00Z",
|
||||||
|
end="2026-09-15T13:30:00Z", all_day=False)
|
||||||
|
check("Zeitpunkt unverändert", e3["start"] == "2026-09-15T12:30:00Z", e3["start"])
|
||||||
|
check("nicht als ganztägig gelesen", e3["all_day"] is False)
|
||||||
|
|
||||||
|
print("\n── Farbe: Name für fremde Clients, Hex für uns ──")
|
||||||
|
e4 = create(summary="T farbe", start="2026-09-16T10:00:00Z",
|
||||||
|
end="2026-09-16T11:00:00Z", all_day=False, color="#ea4335")
|
||||||
|
check("Hex kommt unverändert zurück", e4.get("color") == "#ea4335", e4.get("color"))
|
||||||
|
st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True)
|
||||||
|
block = [b for b in ics.split("BEGIN:VEVENT") if "T farbe" in b]
|
||||||
|
check("COLOR als CSS3-Name im .ics", block and "COLOR:tomato" in block[0],
|
||||||
|
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
|
||||||
|
check("X-AETHER-COLOR mit genauem Wert",
|
||||||
|
block and "#ea4335" in "".join(l for l in block[0].splitlines() if "X-AETHER-COLOR" in l),
|
||||||
|
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
|
||||||
|
|
||||||
|
print("\n── Wöchentlich an bestimmten Tagen ──")
|
||||||
|
# 7. Sept 2026 ist ein Montag. Reihe Mo+Fr, 3 Wochen.
|
||||||
|
create(summary="T wochentage", start="2026-09-07T08:00:00Z",
|
||||||
|
end="2026-09-07T09:00:00Z", all_day=False,
|
||||||
|
recurrence={"freq": "WEEKLY", "interval": 1, "count": 6,
|
||||||
|
"byday": ["MO", "FR"]})
|
||||||
|
got = starts("T wochentage")
|
||||||
|
check("Mo+Fr, 6 Termine", got == ["2026-09-07", "2026-09-11", "2026-09-14",
|
||||||
|
"2026-09-18", "2026-09-21", "2026-09-25"], got)
|
||||||
|
|
||||||
|
print("\n── Monatlich: am festen Tag vs. am n-ten Wochentag ──")
|
||||||
|
# 4. Sept 2026 ist der 1. Freitag des Monats.
|
||||||
|
create(summary="T monat tag", start="2026-09-04T08:00:00Z",
|
||||||
|
end="2026-09-04T09:00:00Z", all_day=False,
|
||||||
|
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "DAY"})
|
||||||
|
got = starts("T monat tag")
|
||||||
|
check("am 4. jedes Monats", got == ["2026-09-04", "2026-10-04", "2026-11-04", "2026-12-04"], got)
|
||||||
|
|
||||||
|
create(summary="T monat wochentag", start="2026-09-04T10:00:00Z",
|
||||||
|
end="2026-09-04T11:00:00Z", all_day=False,
|
||||||
|
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "WEEKDAY"})
|
||||||
|
got = starts("T monat wochentag")
|
||||||
|
# jeweils der 1. Freitag: 4.9. / 2.10. / 6.11. / 4.12.
|
||||||
|
check("am 1. Freitag jedes Monats", got == ["2026-09-04", "2026-10-02", "2026-11-06", "2026-12-04"], got)
|
||||||
|
|
||||||
|
print("\n── Jährlich: beide Muster ──")
|
||||||
|
create(summary="T jahr tag", start="2026-09-04T08:00:00Z",
|
||||||
|
end="2026-09-04T09:00:00Z", all_day=False,
|
||||||
|
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "DAY"})
|
||||||
|
check("am 4. September", starts("T jahr tag") == ["2026-09-04", "2027-09-04"], starts("T jahr tag"))
|
||||||
|
|
||||||
|
create(summary="T jahr wochentag", start="2026-09-04T12:00:00Z",
|
||||||
|
end="2026-09-04T13:00:00Z", all_day=False,
|
||||||
|
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "WEEKDAY"})
|
||||||
|
# 1. Freitag im September: 2026-09-04, 2027-09-03
|
||||||
|
check("am 1. Freitag im September",
|
||||||
|
starts("T jahr wochentag") == ["2026-09-04", "2027-09-03"], starts("T jahr wochentag"))
|
||||||
|
|
||||||
|
print("\n── Regel bleibt beim Bearbeiten erhalten ──")
|
||||||
|
ser = [e for e in events("2026-09-01T00:00:00Z", "2026-12-31T00:00:00Z")
|
||||||
|
if e["summary"] == "T monat wochentag"][0]
|
||||||
|
check("Regel wird als bearbeitbar gemeldet", ser.get("recurrence", {}).get("mode") == "WEEKDAY",
|
||||||
|
ser.get("recurrence"))
|
||||||
|
check("Reihenbeginn mitgeliefert", ser.get("series_start", "")[:10] == "2026-09-04",
|
||||||
|
ser.get("series_start"))
|
||||||
|
st, _ = api("PATCH", f"{EV}/{ser['uid']}",
|
||||||
|
{"summary": "T monat wochentag", "start": "2026-09-04T10:00:00Z",
|
||||||
|
"end": "2026-09-04T11:00:00Z", "all_day": False,
|
||||||
|
"recurrence": {"freq": "MONTHLY", "interval": 2, "count": 3, "mode": "WEEKDAY"}}, CSRF)
|
||||||
|
check("Bearbeiten erlaubt (200)", st == 200, st)
|
||||||
|
got = starts("T monat wochentag")
|
||||||
|
check("jetzt jeden 2. Monat, 1. Freitag", got == ["2026-09-04", "2026-11-06", "2027-01-01"], got)
|
||||||
|
|
||||||
|
print("\n── Zeitfenster begrenzt wirklich ──")
|
||||||
|
win = [e for e in events("2026-09-14T00:00:00Z", "2026-09-20T00:00:00Z")
|
||||||
|
if e["summary"] == "T wochentage"]
|
||||||
|
check("nur die eine Woche", [e["start"][:10] for e in win] == ["2026-09-14", "2026-09-18"],
|
||||||
|
[e["start"][:10] for e in win])
|
||||||
|
|
||||||
|
print("\n── Aufräumen ──")
|
||||||
|
seen = {e["uid"] for e in events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")}
|
||||||
|
for uid in seen:
|
||||||
|
api("DELETE", f"{EV}/{uid}", None, CSRF)
|
||||||
|
rest = events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")
|
||||||
|
check("Kalender wieder leer", rest == [], rest)
|
||||||
|
|
||||||
|
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
|
||||||
|
if fail:
|
||||||
|
print("Fehlgeschlagen: " + ", ".join(fail))
|
||||||
|
sys.exit(1 if fail else 0)
|
||||||
Reference in new issue
Block a user