package calendar import ( "bytes" "context" "crypto/sha1" "io" "net/http" "sort" "strings" "sync" "time" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/jobs" "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/utils" "github.com/emersion/go-ical" ) type RawCalendarData struct { Name string Url string Data []byte Hash [sha1.Size]byte } type CalendarEvent struct { ID string Name string Desc string StartTime time.Time EndTime time.Time Duration time.Duration CalName string } var unifiedCalendar *ical.Calendar var rawCalendarData = make([]*RawCalendarData, 0) var cachedICals = make(map[string][]byte) var httpClient = http.Client{} // NOTE(asaf): Passing an empty array for selectedCals returns all cals func GetICal(selectedCals []string) ([]byte, error) { if unifiedCalendar == nil { return nil, oops.New(nil, "No calendar") } sort.Strings(selectedCals) cacheKey := strings.Join(selectedCals, "##") cachedICal, ok := cachedICals[cacheKey] if ok { return cachedICal, nil } var cal *ical.Calendar if len(selectedCals) == 0 { cal = unifiedCalendar } else { cal = newHMNCalendar() for _, child := range unifiedCalendar.Children { include := true if child.Name == ical.CompEvent { calName, _ := child.Props.Text(ical.PropComment) if calName != "" { found := false for _, s := range selectedCals { if calName == s { found = true } } if !found { include = false } } } if include { cal.Children = append(cal.Children, child) } } } var calBytes []byte if len(cal.Children) > 0 { var buffer bytes.Buffer err := ical.NewEncoder(&buffer).Encode(cal) if err != nil { return nil, oops.New(err, "Failed to encode calendar to iCal") } calBytes = buffer.Bytes() } else { calBytes = emptyCalendarString() } cachedICals[cacheKey] = calBytes return calBytes, nil } func GetFutureEvents() []CalendarEvent { if unifiedCalendar == nil { return nil } futureEvents := make([]CalendarEvent, 0) eventObjects := unifiedCalendar.Events() now := time.Now() lastTime := now.Add(time.Hour * 24 * 365) for _, ev := range eventObjects { summary, err := ev.Props.Text(ical.PropSummary) if err != nil { logging.Error().Err(err).Msg("Failed to get summary for calendar event") continue } startTime, err := ev.DateTimeStart(nil) if err != nil { logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get start time for calendar event") continue } var evTimes []time.Time set, err := ev.RecurrenceSet(nil) if err != nil { logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get recurrence set for calendar event") continue } if set != nil { evTimes = set.Between(now, lastTime, true) } else if startTime.After(now) { evTimes = []time.Time{startTime} } if len(evTimes) == 0 { continue } desc, err := ev.Props.Text(ical.PropDescription) if err != nil { logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get description for calendar event") continue } calName, _ := ev.Props.Text(ical.PropComment) uid, err := ev.Props.Text(ical.PropUID) if err != nil { logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get uid for calendar event") continue } endTime, err := ev.DateTimeStart(nil) if err != nil { logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get end time for calendar event") continue } evDuration := endTime.Sub(startTime) for _, t := range evTimes { futureEvents = append(futureEvents, CalendarEvent{ ID: uid, Name: summary, Desc: desc, StartTime: t, EndTime: t.Add(evDuration), Duration: evDuration, CalName: calName, }) } } sort.Slice(futureEvents, func(i, j int) bool { return futureEvents[i].StartTime.Before(futureEvents[j].StartTime) }) return futureEvents } func MonitorCalendars(ctx context.Context) jobs.Job { log := logging.ExtractLogger(ctx).With().Str("calendar goroutine", "calendar monitor").Logger() if len(config.Config.Calendars) == 0 { log.Info().Msg("No calendars specified in config") return jobs.Noop() } ctx = logging.AttachLoggerToContext(&log, ctx) job := jobs.New() go func() { defer func() { log.Info().Msg("Shutting down calendar monitor") job.Done() }() log.Info().Msg("Running calendar monitor") monitorTimer := time.NewTimer(time.Second) for { select { case <-monitorTimer.C: err := func() (err error) { defer utils.RecoverPanicAsError(&err) ReloadCalendars(ctx) return nil }() if err != nil { logging.Error().Err(err).Msg("Panicked in MonitorCalendars") } monitorTimer.Reset(time.Minute) case <-ctx.Done(): return } } }() return job } func ReloadCalendars(ctx context.Context) { log := logging.ExtractLogger(ctx) // Download calendars calChan := make(chan RawCalendarData, len(config.Config.Calendars)) var wg sync.WaitGroup wg.Add(len(config.Config.Calendars)) for _, c := range config.Config.Calendars { go func(cal config.CalendarSource) { defer func() { wg.Done() logging.LogPanics(log) }() calUrl := cal.Url req, err := http.NewRequestWithContext(ctx, "GET", calUrl, nil) if err != nil { log.Error().Err(err).Msg("Failed to create request for calendar fetch") return } res, err := httpClient.Do(req) if err != nil { log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar") return } if res.StatusCode > 299 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/calendar") { log.Error().Str("Url", calUrl).Str("Status", res.Status).Msg("Failed to fetch calendar") io.ReadAll(res.Body) res.Body.Close() return } data, err := io.ReadAll(res.Body) res.Body.Close() if err != nil { log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar") return } calChan <- RawCalendarData{Name: cal.Name, Url: calUrl, Data: data} }(c) } wg.Wait() newRawCalendarData := make([]*RawCalendarData, 0, len(config.Config.Calendars)) Collect: for { select { case d := <-calChan: newRawCalendarData = append(newRawCalendarData, &d) default: break Collect } } // Diff calendars same := true for _, n := range newRawCalendarData { n.Hash = sha1.Sum(n.Data) } sort.Slice(newRawCalendarData, func(i, j int) bool { return newRawCalendarData[i].Name < newRawCalendarData[j].Name }) if len(newRawCalendarData) != len(rawCalendarData) { same = false } else { for i := range newRawCalendarData { newData := newRawCalendarData[i] oldData := rawCalendarData[i] if newData.Name != oldData.Name { same = false break } if newData.Hash != oldData.Hash { same = false break } } } if same { return } // Unify calendars and clear cache rawCalendarData = newRawCalendarData cachedICals = make(map[string][]byte) unified := newHMNCalendar() var timezones []string for _, calData := range rawCalendarData { decoder := ical.NewDecoder(bytes.NewReader(calData.Data)) calNameProp := ical.NewProp(ical.PropComment) calNameProp.SetText(calData.Name) for { cal, err := decoder.Decode() if err == io.EOF { break } else if err != nil { log.Error().Err(err).Str("Url", calData.Url).Msg("Failed to parse calendar") break } for _, child := range cal.Children { if child.Name == ical.CompTimezone { tzid, err := child.Props.Text(ical.PropTimezoneID) if err != nil { found := false for _, s := range timezones { if s == tzid { found = true } } if found { continue } else { timezones = append(timezones, tzid) } } else { continue } } if child.Name == ical.CompEvent { child.Props.Set(calNameProp) } unified.Children = append(unified.Children, child) } } } unifiedCalendar = unified } func newHMNCalendar() *ical.Calendar { cal := ical.NewCalendar() prodID := ical.NewProp(ical.PropProductID) prodID.SetText("Handmade Network") cal.Props.Set(prodID) version := ical.NewProp(ical.PropVersion) version.SetText("1.0") cal.Props.Set(version) return cal } // NOTE(asaf): The ical library we're using doesn't like encoding empty calendars, so we have to do this manually. func emptyCalendarString() []byte { empty := `BEGIN:VCALENDAR VERSION:1.0 PRODID:Handmade Network END:VCALENDAR ` return []byte(empty) }