hmn/src/calendar/calendar.go

379 lines
8.5 KiB
Go

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)
}