diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index c76edc2..72daf56 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -534,6 +534,10 @@ func main() { r.Delete("/calendars/{id}", calAPIHandler.Delete) r.Get("/calendars/{id}/export", calAPIHandler.Export) r.Post("/calendars/{id}/import", calAPIHandler.Import) + r.Get("/calendars/{id}/events", calAPIHandler.ListEvents) + r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent) + r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent) + r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent) r.Get("/app-passwords", authHandler.ListAppPasswords) r.Post("/app-passwords", authHandler.CreateAppPassword) diff --git a/backend/internal/calendar/backend.go b/backend/internal/calendar/backend.go index abdcda2..ee78981 100644 --- a/backend/internal/calendar/backend.go +++ b/backend/internal/calendar/backend.go @@ -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) diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go new file mode 100644 index 0000000..9e7c28c --- /dev/null +++ b/backend/internal/calendar/events_api.go @@ -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) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4c3dc70..4407437 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -884,4 +884,78 @@ export const adminApi = { request('PATCH', '/admin/settings', data), } +// ── Calendar ───────────────────────────────────────────────────────────── +// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit +// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese +// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen +// Session, wie jeder andere Bereich. + +export interface CalendarItem { + id: string + owner_id: string + name: string + color: string + description: string + supports_todo: boolean + timezone: string + ctag: number + created_at: string +} + +export interface CalendarEvent { + uid: string + summary: string + description: string + location: string + /** ISO 8601, immer UTC ("...Z") – der Server rechnet nicht um, das + * übernimmt der Browser beim Anzeigen (new Date(...) kennt die lokale + * Zeitzone bereits). */ + start: string + end?: string + all_day: boolean + /** Über einen echten CalDAV-Client mit Wiederholung angelegt – hier nur + * sichtbar, nicht bearbeitbar (siehe events_api.go, UpdateEvent). */ + recurring: boolean + etag: string +} + +export interface EventInput { + summary: string + description?: string + location?: string + start: string + end?: string + all_day: boolean +} + +export const calendarApi = { + list: () => request('GET', '/calendars'), + create: (name: string, color: string, description = '') => + request('POST', '/calendars', { name, color, description }), + update: (id: string, patch: Partial>) => + request('PATCH', `/calendars/${id}`, patch), + remove: (id: string) => request('DELETE', `/calendars/${id}`), + + exportUrl: (id: string) => `/api/v1/calendars/${id}/export`, + + import: async (id: string, file: File): Promise<{ imported: number }> => { + const headers: Record = { 'Content-Type': 'text/calendar' } + if (csrfToken) headers['X-CSRF-Token'] = csrfToken + const res = await fetch(`/api/v1/calendars/${id}/import`, { + method: 'POST', headers, credentials: 'include', body: file, + }) + const json = await res.json().catch(() => ({ error: res.statusText })) + if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed') + return json.data + }, + + listEvents: (id: string) => request('GET', `/calendars/${id}/events`), + createEvent: (id: string, input: EventInput) => + request('POST', `/calendars/${id}/events`, input), + updateEvent: (id: string, uid: string, input: EventInput) => + request('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input), + deleteEvent: (id: string, uid: string) => + request('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}`), +} + export { APIError } diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 9cafe91..468830f 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -12,6 +12,7 @@ import SharedWithMePage from '../pages/SharedWithMePage' import AdminPage from '../pages/AdminPage' import ProfilePage from '../pages/ProfilePage' import TagsPage from '../pages/TagsPage' +import CalendarPage from '../pages/CalendarPage' import { UserMenuProvider } from './UserMenu' export type { View } @@ -112,6 +113,7 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) { reloadSignal={trashBump} /> )} + {view === 'calendar' && } {view === 'trash' && } {view === 'incoming' && } {view === 'shared' && } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index aa6e5d1..40657cd 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -36,6 +36,16 @@ const nav: { id: View; label: string; icon: React.ReactNode }[] = [ ), }, + { + id: 'calendar', + label: 'Calendar', + icon: ( + + + + + ), + }, { id: 'trash', label: 'Trash', diff --git a/frontend/src/components/calendar/CalendarDialog.tsx b/frontend/src/components/calendar/CalendarDialog.tsx new file mode 100644 index 0000000..d8ec4d1 --- /dev/null +++ b/frontend/src/components/calendar/CalendarDialog.tsx @@ -0,0 +1,78 @@ +import { useRef, useState, type FormEvent } from 'react' +import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' +import type { CalendarItem } from '../../api/client' + +const palette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#a142f4', '#ff6d00', '#00acc1', '#607d8b'] + +interface Props { + calendar?: CalendarItem + onSave: (name: string, color: string) => void + onCancel: () => void +} + +export default function CalendarDialog({ calendar, onSave, onCancel }: Props) { + const overlay = useOverlayDismiss(onCancel) + const inputRef = useRef(null) + const [name, setName] = useState(calendar?.name ?? '') + const [color, setColor] = useState(calendar?.color ?? palette[0]) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + if (!name.trim()) return + onSave(name.trim(), color) + } + + return ( +
+
+

+ {calendar ? 'Kalender bearbeiten' : 'Neuer Kalender'} +

+ + + setName(e.target.value)} + autoFocus + style={{ + width: '100%', boxSizing: 'border-box', marginBottom: 16, + background: 'var(--color-surface-2)', border: '1px solid var(--color-border)', + borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)', + }} + /> + + +
+ {palette.map(c => ( +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx new file mode 100644 index 0000000..443e2f1 --- /dev/null +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' +import type { CalendarEvent, EventInput } from '../../api/client' + +interface Props { + /** Fehlt sie, entsteht ein neuer Termin. */ + event?: CalendarEvent + /** Vorbelegter Starttag – aus dem angeklickten Tag der Monats-/Wochenansicht. */ + defaultDate: Date + onSave: (input: EventInput) => void + onDelete?: () => void + onCancel: () => void +} + +function toDateInput(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} +function toTimeInput(d: Date): string { + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +export default function EventDialog({ event, defaultDate, onSave, onDelete, onCancel }: Props) { + const overlay = useOverlayDismiss(onCancel) + const titleRef = useRef(null) + + const start = event ? new Date(event.start) : defaultDate + const end = event?.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000) + + const [summary, setSummary] = useState(event?.summary ?? '') + const [description, setDescription] = useState(event?.description ?? '') + const [location, setLocation] = useState(event?.location ?? '') + const [allDay, setAllDay] = useState(event?.all_day ?? false) + const [startDate, setStartDate] = useState(toDateInput(start)) + const [startTime, setStartTime] = useState(toTimeInput(start)) + const [endDate, setEndDate] = useState(toDateInput(end)) + const [endTime, setEndTime] = useState(toTimeInput(end)) + + useEffect(() => { titleRef.current?.focus() }, []) + + const readOnly = !!event?.recurring + + function handleSubmit(e: FormEvent) { + e.preventDefault() + if (readOnly || !summary.trim()) return + const s = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`) + const en = allDay ? new Date(`${endDate}T00:00:00`) : new Date(`${endDate}T${endTime}:00`) + onSave({ + summary: summary.trim(), + description: description.trim() || undefined, + location: location.trim() || undefined, + all_day: allDay, + start: s.toISOString(), + end: en.toISOString(), + }) + } + + const inputStyle: React.CSSProperties = { + width: '100%', boxSizing: 'border-box', + background: 'var(--color-surface-2)', border: '1px solid var(--color-border)', + borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)', + } + const labelStyle: React.CSSProperties = { + fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block', + } + + return ( +
+
+

+ {event ? 'Termin bearbeiten' : 'Neuer Termin'} +

+ + {readOnly && ( +
+ Wiederholender Termin – nur mit einem CalDAV-Client (z. B. DAVx5) bearbeitbar, nicht hier. +
+ )} + +
+ + setSummary(e.target.value)} style={inputStyle} required /> +
+ + + +
+
+ + setStartDate(e.target.value)} style={inputStyle} /> +
+ {!allDay && ( +
+ + setStartTime(e.target.value)} style={inputStyle} /> +
+ )} +
+ +
+
+ + setEndDate(e.target.value)} style={inputStyle} /> +
+ {!allDay && ( +
+ + setEndTime(e.target.value)} style={inputStyle} /> +
+ )} +
+ +
+ + setLocation(e.target.value)} style={inputStyle} /> +
+ +
+ +