feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht
Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.
Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.
Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d635fff20
commit
27a026421f
10 files changed
+1163
-6
No files matched your search
@@ -294,6 +294,14 @@ func timeRange(cal *ical.Calendar) (start, end *time.Time) {
|
||||
return
|
||||
}
|
||||
|
||||
func encodeICal(cal *ical.Calendar) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||||
return "", fmt.Errorf("encode object: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func etagFor(data string) string {
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(sum[:])
|
||||
@@ -328,11 +336,10 @@ func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Cal
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||||
return nil, fmt.Errorf("encode object: %w", err)
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := buf.String()
|
||||
etag := etagFor(data)
|
||||
start, end := timeRange(cal)
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
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 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 = startProp.ValueType() == ical.ValueDate
|
||||
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 {
|
||||
dto.End = &t
|
||||
}
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
ev.Props.SetDate(ical.PropDateTimeStart, body.Start)
|
||||
if body.End != nil {
|
||||
ev.Props.SetDate(ical.PropDateTimeEnd, *body.End)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
Reference in new issue
Block a user