package calendar import ( "context" "fmt" "net/http" "strings" "time" "github.com/emersion/go-ical" "github.com/emersion/go-webdav/caldav" ) // CalDAVConnConfig deckt iCloud (https://caldav.icloud.com mit App-spezifischem // Passwort) sowie Fastmail, Nextcloud und andere CalDAV-Server ab. type CalDAVConnConfig struct { ServerURL string `json:"server_url"` Username string `json:"username"` Password string `json:"password"` CalendarPath string `json:"calendar_path"` } type CalDAVProvider struct { cfg CalDAVConnConfig } func NewCalDAVProvider(cfg CalDAVConnConfig) *CalDAVProvider { return &CalDAVProvider{cfg: cfg} } type basicAuthTransport struct { username, password string } func (t *basicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { clone := req.Clone(req.Context()) clone.SetBasicAuth(t.username, t.password) return http.DefaultTransport.RoundTrip(clone) } func newCalDAVClient(cfg CalDAVConnConfig) (*caldav.Client, error) { hc := &http.Client{Transport: &basicAuthTransport{cfg.Username, cfg.Password}} return caldav.NewClient(hc, cfg.ServerURL) } // DiscoverCalDAV findet automatisch den ersten Kalender mit Terminen (VEVENT) // auf dem Server. Für iCloud: Apple-ID als Benutzername und ein im Apple-ID- // Konto erzeugtes App-spezifisches Passwort. func DiscoverCalDAV(ctx context.Context, cfg CalDAVConnConfig) (path, name string, err error) { c, err := newCalDAVClient(cfg) if err != nil { return "", "", fmt.Errorf("CalDAV-Server-URL ungültig: %w", err) } principal, err := c.FindCurrentUserPrincipal(ctx) if err != nil { return "", "", fmt.Errorf("CalDAV: Anmeldung/Principal fehlgeschlagen (Zugangsdaten prüfen; iCloud braucht ein App-spezifisches Passwort): %w", err) } homeSet, err := c.FindCalendarHomeSet(ctx, principal) if err != nil { return "", "", fmt.Errorf("CalDAV: Kalender-Verzeichnis nicht gefunden: %w", err) } cals, err := c.FindCalendars(ctx, homeSet) if err != nil { return "", "", fmt.Errorf("CalDAV: Kalender konnten nicht geladen werden: %w", err) } for _, cal := range cals { for _, comp := range cal.SupportedComponentSet { if strings.EqualFold(comp, "VEVENT") { return cal.Path, cal.Name, nil } } } return "", "", fmt.Errorf("CalDAV: kein Kalender mit Terminen gefunden") } func (p *CalDAVProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) { c, err := newCalDAVClient(p.cfg) if err != nil { return nil, err } // Der Zeitfilter auf dem Server expandiert auch Serientermine (RFC 4791). query := &caldav.CalendarQuery{ CompRequest: caldav.CalendarCompRequest{ Name: "VCALENDAR", Comps: []caldav.CalendarCompRequest{{Name: "VEVENT"}}, }, CompFilter: caldav.CompFilter{ Name: "VCALENDAR", Comps: []caldav.CompFilter{{ Name: "VEVENT", Start: from, End: to, }}, }, } objects, err := c.QueryCalendar(ctx, p.cfg.CalendarPath, query) if err != nil { return nil, fmt.Errorf("CalDAV-Abfrage fehlgeschlagen: %w", err) } var out []Interval for _, obj := range objects { if obj.Data == nil { continue } for _, ev := range obj.Data.Events() { if prop := ev.Props.Get(ical.PropStatus); prop != nil && strings.EqualFold(prop.Value, "CANCELLED") { continue } // Transparente Termine ("Verfügbar") blockieren keine Zeit. if prop := ev.Props.Get("TRANSP"); prop != nil && strings.EqualFold(prop.Value, "TRANSPARENT") { continue } start, err := ev.DateTimeStart(time.UTC) if err != nil { continue } end, err := ev.DateTimeEnd(time.UTC) if err != nil || !end.After(start) { continue } out = append(out, Interval{Start: start, End: end}) } } return out, nil }