384 lines
8.6 KiB
Go
384 lines
8.6 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)
|
|
|
|
name := ical.NewProp("X-WR-CALNAME")
|
|
name.SetText("Handmade Network")
|
|
cal.Props.Set(name)
|
|
|
|
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
|
|
X-WR-CALNAME:Handmade Network
|
|
END:VCALENDAR
|
|
`
|
|
|
|
return []byte(empty)
|
|
}
|