Files
Aether/backend/internal/calendar/events_api.go
T
marcandClaude Opus 5 79a7cc6a3d feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter
Fünf Wünsche aus dem Durchtesten:

1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich,
   alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand
   lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe
   erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im
   angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals
   RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an.
   Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt
   neu gerechnet, Fenster einer Woche liefert genau einen.

   Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag
   im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier
   Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein
   halb verstandenes Speichern würde die Regel sonst still beschneiden.

2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick
   auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen
   aus oder legt bewusst einen neuen an.

3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe.

4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter --
   gefüllt heißt sichtbar, leerer Ring ausgeblendet.

5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das
   Formular um, das ist keine Nebenangabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:56:28 +02:00

518 lines
17 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"
)
// 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.
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"`
}
// 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
}
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 {
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 {
return nil
}
if opt.Byweekday[0].Day() != int(start.Weekday()+6)%7 {
return nil
}
}
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
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)
color, _ := ev.Props.Text(propColor)
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 body.Color != "" {
ev.Props.SetText(propColor, body.Color)
}
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}
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)
}