Drei gemeldete Fehler, alle nachgestellt: 1. Ein vorhandener Termin ließ sich nicht öffnen. Der Klick auf den Termin reichte an die Tageszelle darunter durch, die sofort 'Neuer Termin' öffnete und den Bearbeiten-Dialog überschrieb. Fehlendes stopPropagation. 2. Klick auf den 3. legte den Termin am 2. an. Ein ganztägiger Termin ist ein *Datum*, kein Zeitpunkt - die Oberfläche rechnete ihn über new Date(...).toISOString() trotzdem in UTC um, was östlich von UTC den Vortag ergibt. Nachgestellt und bestätigt: DTSTART landete als 20260902 statt 20260903. Ganztägige Termine laufen jetzt über dates.ts (allDayISO/eventDay), damit Dialog und Raster dieselbe Rechnung benutzen. Nebenbefund mitgefixt: DTEND war gleich DTSTART, was RFC 5545 verbietet (DTEND ist exklusiv) - ein eintägiger Termin am 3. schreibt jetzt DTEND 20260904, nach außen bleibt die Schnittstelle beim letzten belegten Tag. 3. 'Fühlt sich starr an': Wochenansicht ist jetzt ein echtes Stundenraster (Klick trifft die Stunde statt Mitternacht, ganztägige in eigener Zeile, überlappende Termine nebeneinander, Jetzt-Linie), mehrtägige Termine stehen in jeder Zelle statt nur am Starttag, neue Termine schlagen die nächste volle Stunde bzw. 9 Uhr vor, Kalenderauswahl im Termindialog, Escape schließt, Hover-Rückmeldung, Kalenderliste auf schmalen Schirmen einklappbar. Außerdem: ein neu angelegter Kalender blieb unsichtbar, weil er in keiner Sichtbar-Liste stand - die Liste merkt sich jetzt die ausgeblendeten statt der sichtbaren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
10 KiB
Go
314 lines
10 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"
|
||
|
||
"aether/internal/auth"
|
||
"aether/pkg/response"
|
||
)
|
||
|
||
// 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.
|
||
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"`
|
||
Recurring bool `json:"recurring"`
|
||
ETag string `json:"etag"`
|
||
}
|
||
|
||
func eventFromObject(o *Object) (*EventDTO, error) {
|
||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||
cal, err := dec.Decode()
|
||
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
|
||
}
|
||
}
|
||
if ev == nil {
|
||
return nil, errNoEventComponent
|
||
}
|
||
|
||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||
description, _ := ev.Props.Text(ical.PropDescription)
|
||
location, _ := ev.Props.Text(ical.PropLocation)
|
||
_, recurring := ev.Props["RRULE"]
|
||
|
||
dto := &EventDTO{
|
||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||
Recurring: recurring, 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
|
||
}
|
||
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
|
||
}
|
||
|
||
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
||
|
||
type icalError struct{ msg string }
|
||
|
||
func (e *icalError) Error() string { return e.msg }
|
||
|
||
// GET /api/v1/calendars/{id}/events
|
||
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
|
||
}
|
||
out := make([]EventDTO, 0, len(objs))
|
||
for i := range objs {
|
||
dto, err := eventFromObject(&objs[i])
|
||
if err != nil {
|
||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||
}
|
||
out = append(out, *dto)
|
||
}
|
||
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"`
|
||
}
|
||
|
||
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.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())
|
||
}
|
||
}
|
||
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) {
|
||
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
|
||
}
|
||
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()
|
||
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)
|
||
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}
|
||
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 {
|
||
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
|
||
}
|
||
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")
|
||
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
|
||
}
|
||
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)
|
||
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)
|
||
}
|
||
|
||
// 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 {
|
||
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)
|
||
}
|