package calendar import ( "context" "fmt" "io" "net/http" "strconv" "strings" "time" ) // ICSConnConfig: Abo eines beliebigen Kalenders per ICS-/webcal-Link // (z. B. die "private Adresse im iCal-Format" von Google oder Outlook-Feeds). type ICSConnConfig struct { URL string `json:"url"` } type ICSProvider struct { URL string // DefaultTZ wird für "floating" Zeiten (ohne TZID/UTC) genutzt, // typischerweise die Zeitzone des Kalenderbesitzers. DefaultTZ string } func NewICSProvider(cfg ICSConnConfig, defaultTZ string) *ICSProvider { return &ICSProvider{URL: cfg.URL, DefaultTZ: defaultTZ} } type icsEvent struct { start time.Time hasStart bool allDay bool end time.Time cancelled bool transparent bool } func (p *ICSProvider) FetchBusy(ctx context.Context, from, to time.Time) ([]Interval, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "WannPassts/1.0 (+calendar-sync)") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("ICS-Link nicht abrufbar: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("ICS-Link antwortet mit HTTP %d", resp.StatusCode) } raw, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) if err != nil { return nil, fmt.Errorf("ICS-Antwort nicht lesbar: %w", err) } defLoc := time.UTC if p.DefaultTZ != "" { if loc, err := time.LoadLocation(p.DefaultTZ); err == nil { defLoc = loc } } events := parseICS(string(raw), defLoc) var out []Interval for _, ev := range events { if ev.cancelled || ev.transparent || !ev.hasStart { continue } end := ev.end if end.IsZero() || !end.After(ev.start) { if ev.allDay { end = ev.start.Add(24 * time.Hour) } else { continue } } out = append(out, Interval{Start: ev.start, End: end}) } return clampIntervals(out, from, to), nil } // parseICS liest VEVENTs aus ICS-Daten. Zeilen werden entfaltet (RFC 5545), // Parameter wie TZID/VALUE=DATE ausgewertet. Serientermine werden als ihre // Basis-Instanz übernommen – ICS-Abos sind damit der "best effort"-Weg, // CalDAV und Google expandieren Serien serverseitig korrekt. func parseICS(input string, defLoc *time.Location) []icsEvent { input = strings.ReplaceAll(input, "\r\n", "\n") rawLines := strings.Split(input, "\n") lines := make([]string, 0, len(rawLines)) for _, l := range rawLines { if strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t") { if len(lines) > 0 { lines[len(lines)-1] += l[1:] } continue } lines = append(lines, strings.TrimSuffix(l, "\r")) } var events []icsEvent var cur *icsEvent for _, line := range lines { upper := strings.ToUpper(strings.TrimSpace(line)) switch { case upper == "BEGIN:VEVENT": cur = &icsEvent{} continue case upper == "END:VEVENT": if cur != nil { events = append(events, *cur) cur = nil } continue } if cur == nil { continue } name, params, value := splitICSLine(line) switch name { case "DTSTART": if t, allDay, ok := parseICSDateTime(value, params, defLoc); ok { cur.start, cur.hasStart, cur.allDay = t, true, allDay } case "DTEND": if t, _, ok := parseICSDateTime(value, params, defLoc); ok { cur.end = t } case "DURATION": if cur.hasStart { if d, ok := parseISODuration(value); ok { cur.end = cur.start.Add(d) } } case "STATUS": if strings.EqualFold(value, "CANCELLED") { cur.cancelled = true } case "TRANSP": if strings.EqualFold(value, "TRANSPARENT") { cur.transparent = true } } } return events } // splitICSLine zerlegt "NAME;PARAM=WERT;...:value" am ersten Doppelpunkt. func splitICSLine(line string) (name string, params map[string]string, value string) { params = map[string]string{} i := strings.IndexByte(line, ':') if i == -1 { return strings.ToUpper(strings.TrimSpace(line)), params, "" } head, value := line[:i], strings.TrimSpace(line[i+1:]) parts := strings.Split(head, ";") name = strings.ToUpper(strings.TrimSpace(parts[0])) for _, p := range parts[1:] { if eq := strings.IndexByte(p, '='); eq > 0 { key := strings.ToUpper(strings.TrimSpace(p[:eq])) params[key] = strings.Trim(strings.TrimSpace(p[eq+1:]), `"`) } } return name, params, value } func parseICSDateTime(value string, params map[string]string, defLoc *time.Location) (t time.Time, allDay, ok bool) { value = strings.TrimSpace(value) if value == "" { return time.Time{}, false, false } if params["VALUE"] == "DATE" || (len(value) == 8 && allDigits(value)) { y, err1 := strconv.Atoi(value[0:4]) m, err2 := strconv.Atoi(value[4:6]) d, err3 := strconv.Atoi(value[6:8]) if err1 != nil || err2 != nil || err3 != nil { return time.Time{}, false, false } return time.Date(y, time.Month(m), d, 0, 0, 0, 0, defLoc), true, true } layouts := []struct { layout string utc bool }{ {"20060102T150405Z", true}, {"20060102T150405", false}, {"2006-01-02T15:04:05Z07:00", true}, {"2006-01-02T15:04:05", false}, } for _, l := range layouts { if parsed, err := time.Parse(l.layout, value); err == nil { if l.utc { return parsed.UTC(), false, true } // Wanduhrzeit in der Zielzone interpretieren (nicht instant-konvertieren) loc := defLoc if tzid := params["TZID"]; tzid != "" { if loaded, err := time.LoadLocation(tzid); err == nil { loc = loaded } } y, mo, d := parsed.Date() hh, mm, ss := parsed.Clock() return time.Date(y, mo, d, hh, mm, ss, 0, loc), false, true } } return time.Time{}, false, false } func allDigits(s string) bool { for _, c := range s { if c < '0' || c > '9' { return false } } return len(s) > 0 } // parseISODuration versteht die üblichen ICS-Dauerformen: PT15M, PT1H30M, // P1D, P2W, PT45S … func parseISODuration(s string) (time.Duration, bool) { s = strings.TrimSpace(strings.ToUpper(s)) if len(s) < 2 || s[0] != 'P' || strings.Contains(s, "-") { return 0, false } body := s[1:] if strings.HasSuffix(body, "W") { v, err := strconv.ParseFloat(strings.TrimSuffix(body, "W"), 64) if err != nil { return 0, false } return time.Duration(v * float64(7*24*time.Hour)), true } if strings.HasPrefix(body, "T") { body = body[1:] } units := map[byte]time.Duration{ 'D': 24 * time.Hour, 'H': time.Hour, 'M': time.Minute, 'S': time.Second, } var total time.Duration num := "" flush := func(unit time.Duration) bool { if num == "" { return false } v, err := strconv.ParseFloat(num, 64) if err != nil { return false } total += time.Duration(v * float64(unit)) num = "" return true } for i := 0; i < len(body); i++ { c := body[i] if (c >= '0' && c <= '9') || c == '.' { num += string(c) continue } unit, known := units[c] if !known || !flush(unit) { return 0, false } } return total, num == "" }