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>
663 lines
21 KiB
Go
663 lines
21 KiB
Go
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)
|
||
}
|