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>
This commit is contained in:
1 parent
105ce8de4a
commit
79a7cc6a3d
8 files changed
+795
-218
No files matched your search
+1
-1
@@ -13,6 +13,7 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/net v0.56.0
|
||||
@@ -29,7 +30,6 @@ require (
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
|
||||
@@ -9,19 +9,44 @@ import (
|
||||
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 – reicht für Anlegen/Ändern eines einfachen Termins im Browser. Ein
|
||||
// über einen echten CalDAV-Client angelegter Termin mit Wiederholung bleibt
|
||||
// über /caldav/ voll erhalten, zeigt sich hier nur mit Recurring=true und
|
||||
// lässt sich in der Web-Oberfläche bewusst nicht bearbeiten (siehe Update) –
|
||||
// eine RRULE über dieses vereinfachte Formular zu verändern, würde sie eher
|
||||
// beschädigen als korrekt anpassen.
|
||||
// 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"`
|
||||
@@ -31,11 +56,91 @@ type EventDTO struct {
|
||||
// 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"`
|
||||
Recurring bool `json:"recurring"`
|
||||
ETag string `json:"etag"`
|
||||
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) {
|
||||
@@ -44,13 +149,7 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ev *ical.Component
|
||||
for _, child := range cal.Children {
|
||||
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
||||
ev = child
|
||||
break
|
||||
}
|
||||
}
|
||||
ev := firstEventComponent(cal)
|
||||
if ev == nil {
|
||||
return nil, errNoEventComponent
|
||||
}
|
||||
@@ -58,11 +157,11 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||
description, _ := ev.Props.Text(ical.PropDescription)
|
||||
location, _ := ev.Props.Text(ical.PropLocation)
|
||||
_, recurring := ev.Props["RRULE"]
|
||||
color, _ := ev.Props.Text(propColor)
|
||||
|
||||
dto := &EventDTO{
|
||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||
Recurring: recurring, ETag: o.ETag,
|
||||
Color: color, ETag: o.ETag,
|
||||
}
|
||||
|
||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||
@@ -93,14 +192,67 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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"}
|
||||
@@ -109,7 +261,30 @@ type icalError struct{ msg string }
|
||||
|
||||
func (e *icalError) Error() string { return e.msg }
|
||||
|
||||
// GET /api/v1/calendars/{id}/events
|
||||
// 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"))
|
||||
@@ -126,24 +301,28 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusInternalServerError, "list events failed")
|
||||
return
|
||||
}
|
||||
from, to := parseWindow(r)
|
||||
|
||||
out := make([]EventDTO, 0, len(objs))
|
||||
for i := range objs {
|
||||
dto, err := eventFromObject(&objs[i])
|
||||
base, err := eventFromObject(&objs[i])
|
||||
if err != nil {
|
||||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||||
}
|
||||
out = append(out, *dto)
|
||||
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"`
|
||||
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 {
|
||||
@@ -163,6 +342,10 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||
if body.Location != "" {
|
||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||
}
|
||||
if body.Color != "" {
|
||||
ev.Props.SetText(propColor, body.Color)
|
||||
}
|
||||
|
||||
if body.AllDay {
|
||||
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
|
||||
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
|
||||
@@ -190,19 +373,56 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||
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
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/events
|
||||
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
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
|
||||
}
|
||||
@@ -213,35 +433,23 @@ func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
uid := uuid.New().String()
|
||||
cal := buildEventICal(uid, body)
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "encode event failed")
|
||||
return
|
||||
}
|
||||
start, end := timeRange(cal)
|
||||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
||||
dto, err := h.writeEvent(r, calID, uid, body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "create event failed")
|
||||
return
|
||||
}
|
||||
dto, err := eventFromObject(obj)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "read back 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) {
|
||||
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 {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
@@ -255,12 +463,11 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusInternalServerError, "load event failed")
|
||||
return
|
||||
}
|
||||
existingDTO, err := eventFromObject(existing)
|
||||
if err == nil && existingDTO.Recurring {
|
||||
// Diese Ansicht kennt keine RRULE-Bearbeitung – ein Formular mit nur
|
||||
// "Titel/Start/Ende" würde die Wiederholungsregel beim Speichern
|
||||
// stillschweigend verwerfen. Lieber ablehnen als Daten beschädigen.
|
||||
response.Error(w, http.StatusConflict, "recurring events can only be edited in a CalDAV client")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -269,35 +476,32 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
cal := buildEventICal(uid, body)
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "encode event failed")
|
||||
return
|
||||
}
|
||||
start, end := timeRange(cal)
|
||||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
||||
dto, err := h.writeEvent(r, calID, uid, body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update event failed")
|
||||
return
|
||||
}
|
||||
dto, err := eventFromObject(obj)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "read back 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) {
|
||||
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 {
|
||||
calID, ok := h.calendarOf(r)
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||
return
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user