Files
Aether/backend/internal/calendar/events_api.go
T
marcandClaude Opus 5 0c9cc69493 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>
2026-09-04 01:16:06 +02:00

663 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package calendar
import (
"encoding/json"
"net/http"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/teambition/rrule-go"
"aether/internal/auth"
"aether/pkg/response"
)
// 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.
const maxOccurrences = 750
// RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche
// anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer
// Anzahl oder an einem Datum.
//
// Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im
// August"). Solche Regeln werden gelesen und unverändert behalten, aber
// nicht in dieses Formular gezwängt – siehe recurrenceFromRule.
type RecurrenceDTO struct {
Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY
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.
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
// ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt
// im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur,
// was das Formular auch wirklich kennt.
type EventDTO struct {
UID string `json:"uid"`
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
// Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten
// belegten** Tages – in beide Richtungen gleich. Die exklusive
// DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig
// hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag.
Start time.Time `json:"start"`
End *time.Time `json:"end,omitempty"`
AllDay bool `json:"all_day"`
// Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur
// für diesen einen Termin.
Color string `json:"color,omitempty"`
// Recurring: es hängt überhaupt eine Wiederholung dran.
// Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt.
// Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum
// Bearbeiten" – dann ist Recurring true und Recurrence nil.
Recurring bool `json:"recurring"`
Recurrence *RecurrenceDTO `json:"recurrence,omitempty"`
// Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen*
// Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular
// bearbeitet immer die Reihe und braucht daher deren Anfang.
SeriesStart *time.Time `json:"series_start,omitempty"`
SeriesEnd *time.Time `json:"series_end,omitempty"`
ETag string `json:"etag"`
}
func firstEventComponent(cal *ical.Calendar) *ical.Component {
for _, child := range cal.Children {
if child.Name == "VEVENT" || child.Name == "VTODO" {
return child
}
}
return nil
}
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
func isDateOnly(prop *ical.Prop) bool {
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
}
var freqToString = map[rrule.Frequency]string{
rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY",
rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY",
}
var stringToFreq = map[string]rrule.Frequency{
"DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY,
"MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY,
}
// recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder
// gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig
// ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb
// verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das
// Speichern die verlorenen Teile still weg.
func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
if opt == nil {
return nil
}
freq, ok := freqToString[opt.Freq]
if !ok {
return nil // stündlich und feiner bietet die Oberfläche nicht an
}
// 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
}
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
}
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
}
}
if dto.Interval < 1 {
dto.Interval = 1
}
if !opt.Until.IsZero() {
u := opt.Until.UTC()
dto.Until = &u
}
return dto
}
func eventFromObject(o *Object) (*EventDTO, error) {
dec := ical.NewDecoder(strings.NewReader(o.Data))
cal, err := dec.Decode()
if err != nil {
return nil, err
}
ev := firstEventComponent(cal)
if ev == nil {
return nil, errNoEventComponent
}
summary, _ := ev.Props.Text(ical.PropSummary)
description, _ := ev.Props.Text(ical.PropDescription)
location, _ := ev.Props.Text(ical.PropLocation)
// 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,
Color: color, ETag: o.ETag,
}
startProp := ev.Props.Get(ical.PropDateTimeStart)
if startProp != nil {
dto.AllDay = isDateOnly(startProp)
if t, err := startProp.DateTime(time.UTC); err == nil {
dto.Start = t
}
}
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
if t, err := endProp.DateTime(time.UTC); err == nil {
// Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein
// eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt
// diese Schnittstelle den letzten *belegten* Tag zurück – sonst
// müsste jede Oberfläche dieselbe Rechnung noch einmal machen
// und eine davon würde sie vergessen.
if dto.AllDay {
t = t.AddDate(0, 0, -1)
if t.Before(dto.Start) {
t = dto.Start
}
}
dto.End = &t
}
}
if dto.AllDay && dto.End == nil {
// Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag.
end := dto.Start
dto.End = &end
}
if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil {
dto.Recurring = true
dto.Recurrence = recurrenceFromRule(opt, dto.Start)
}
return dto, nil
}
// expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne
// Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau
// **einen** Eintrag – nämlich den ersten.
func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO {
if !base.Recurring {
// Einzeltermin: nur, wenn er das Fenster überhaupt berührt.
end := base.Start
if base.End != nil {
end = *base.End
}
if end.Before(from) || base.Start.After(to) {
return nil
}
return []EventDTO{*base}
}
dec := ical.NewDecoder(strings.NewReader(o.Data))
cal, err := dec.Decode()
if err != nil {
return []EventDTO{*base}
}
ev := firstEventComponent(cal)
if ev == nil {
return []EventDTO{*base}
}
set, err := ev.RecurrenceSet(time.UTC)
if err != nil || set == nil {
return []EventDTO{*base}
}
// Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert.
var dur time.Duration
if base.End != nil {
dur = base.End.Sub(base.Start)
}
seriesStart, seriesEnd := base.Start, base.End
out := make([]EventDTO, 0, 16)
for i, occ := range set.Between(from, to, true) {
if i >= maxOccurrences {
break
}
item := *base
item.Start = occ.UTC()
if seriesEnd != nil {
end := item.Start.Add(dur)
item.End = &end
}
item.SeriesStart = &seriesStart
item.SeriesEnd = seriesEnd
out = append(out, item)
}
return out
}
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
type icalError struct{ msg string }
func (e *icalError) Error() string { return e.msg }
// parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges
// Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem
// etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird.
func parseWindow(r *http.Request) (from, to time.Time) {
now := time.Now().UTC()
from = now.AddDate(0, -1, 0)
to = now.AddDate(1, 0, 0)
if v := r.URL.Query().Get("from"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
from = t.UTC()
}
}
if v := r.URL.Query().Get("to"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
to = t.UTC()
}
}
if to.Before(from) {
to = from
}
return from, to
}
// GET /api/v1/calendars/{id}/events?from=…&to=…
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
calID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
objs, err := h.store.ListObjects(r.Context(), calID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "list events failed")
return
}
from, to := parseWindow(r)
out := make([]EventDTO, 0, len(objs))
for i := range objs {
base, err := eventFromObject(&objs[i])
if err != nil {
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
}
out = append(out, expand(&objs[i], base, from, to)...)
}
response.JSON(w, http.StatusOK, out)
}
type eventBody struct {
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
Start time.Time `json:"start"`
End *time.Time `json:"end"`
AllDay bool `json:"all_day"`
Color string `json:"color"`
Recurrence *RecurrenceDTO `json:"recurrence"`
}
func buildEventICal(uid string, body eventBody) *ical.Calendar {
root := ical.NewCalendar()
root.Props.SetText(ical.PropVersion, "2.0")
root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
ev := ical.NewEvent()
ev.Props.SetText(ical.PropUID, uid)
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
if body.Summary != "" {
ev.Props.SetText(ical.PropSummary, body.Summary)
}
if body.Description != "" {
ev.Props.SetText(ical.PropDescription, body.Description)
}
if body.Location != "" {
ev.Props.SetText(ical.PropLocation, body.Location)
}
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 {
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
// Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine
// Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der
// 3. wurde zum 2.).
start := body.Start.UTC()
ev.Props.SetDate(ical.PropDateTimeStart, start)
// DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten
// belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen
// (siehe eventFromObject), also hier +1. Ohne das verletzt ein
// eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und
// Clients zeigen ihn unterschiedlich an.
last := start
if body.End != nil {
last = body.End.UTC()
if last.Before(start) {
last = start
}
}
ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1))
} else {
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
if body.End != nil {
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
}
}
if rule := ruleFromRecurrence(body); rule != nil {
ev.Props.SetRecurrenceRule(rule)
}
root.Children = append(root.Children, ev.Component)
return root
}
func ruleFromRecurrence(body eventBody) *rrule.ROption {
rec := body.Recurrence
if rec == nil {
return nil
}
freq, ok := stringToFreq[strings.ToUpper(rec.Freq)]
if !ok {
return nil
}
interval := rec.Interval
if interval < 1 {
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
// Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen
// später bemerkt.
u := rec.Until.UTC()
opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC)
}
return opt
}
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
user := auth.UserFromContext(r.Context())
calID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
return uuid.UUID{}, false
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
return uuid.UUID{}, false
}
return calID, true
}
// POST /api/v1/calendars/{id}/events
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
calID, ok := h.calendarOf(r)
if !ok {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
var body eventBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
response.Error(w, http.StatusBadRequest, "summary and start are required")
return
}
uid := uuid.New().String()
dto, err := h.writeEvent(r, calID, uid, body)
if err != nil {
response.Error(w, http.StatusInternalServerError, "create event failed")
return
}
response.JSON(w, http.StatusCreated, dto)
}
// PATCH /api/v1/calendars/{id}/events/{uid}
//
// Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne
// Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine
// eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne
// die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen.
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
calID, ok := h.calendarOf(r)
if !ok {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
uid := chi.URLParam(r, "uid")
existing, err := h.store.GetObjectByUID(r.Context(), calID, uid)
if err == ErrNotFound {
response.Error(w, http.StatusNotFound, "event not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "load event failed")
return
}
if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil {
// Wiederholung, die das Formular nicht vollständig abbildet (etwa
// „jeden zweiten Dienstag"). Speichern würde die Regel auf das
// reduzieren, was das Formular kennt – also lieber ablehnen.
response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client")
return
}
var body eventBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
response.Error(w, http.StatusBadRequest, "summary and start are required")
return
}
dto, err := h.writeEvent(r, calID, uid, body)
if err != nil {
response.Error(w, http.StatusInternalServerError, "update event failed")
return
}
response.JSON(w, http.StatusOK, dto)
}
func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) {
cal := buildEventICal(uid, body)
data, err := encodeICal(cal)
if err != nil {
return nil, err
}
start, end := timeRange(cal)
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
if err != nil {
return nil, err
}
return eventFromObject(obj)
}
// DELETE /api/v1/calendars/{id}/events/{uid}
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
calID, ok := h.calendarOf(r)
if !ok {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
uid := chi.URLParam(r, "uid")
if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound {
response.Error(w, http.StatusNotFound, "event not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "delete event failed")
return
}
response.NoContent(w)
}