diff --git a/go.mod b/go.mod index 6c511c4..b36debb 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect @@ -56,6 +57,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/teambition/rrule-go v1.7.2 // indirect go.uber.org/atomic v1.10.0 // indirect golang.org/x/net v0.6.0 // indirect golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 // indirect diff --git a/go.sum b/go.sum index ad289ec..4445ca1 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ= +github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -275,6 +277,8 @@ github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vA github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI= github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg= github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk= +github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0= +github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/wellington/go-libsass v0.9.2 h1:6Ims04UDdBs6/CGSVK5JC8FNikR5ssrsMMKE/uaO5Q8= github.com/wellington/go-libsass v0.9.2/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs= diff --git a/src/calendar/calendar.go b/src/calendar/calendar.go new file mode 100644 index 0000000..a3d5830 --- /dev/null +++ b/src/calendar/calendar.go @@ -0,0 +1,378 @@ +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) +} diff --git a/src/config/config.go.example b/src/config/config.go.example index c94a59c..ef402e3 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -78,6 +78,8 @@ var Config = HMNConfig{ BaseUrl: "https://api.twitch.tv/helix", BaseIDUrl: "https://id.twitch.tv/oauth2", }, + Calendars: []CalendarSource{ + }, EpisodeGuide: EpisodeGuide{ CineraOutputPath: "./annotations/", Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"}, diff --git a/src/config/types.go b/src/config/types.go index 650e638..f43baf2 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -32,6 +32,7 @@ type HMNConfig struct { EpisodeGuide EpisodeGuide DevConfig DevConfig PreviewGeneration PreviewGenerationConfig + Calendars []CalendarSource } type PostgresConfig struct { @@ -101,6 +102,11 @@ type MatrixConfig struct { AnnouncementsRoomID string } +type CalendarSource struct { + Name string + Url string +} + type EpisodeGuide struct { CineraOutputPath string Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 55d20bd..66933ac 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -171,6 +171,20 @@ func BuildTimeMachineFormDone() string { return Url("/timemachine/thanks", nil) } +var RegexCalendarIndex = regexp.MustCompile("^/calendar$") + +func BuildCalendarIndex() string { + defer CatchPanic() + return Url("/calendar", nil) +} + +var RegexCalendarICal = regexp.MustCompile("^/calendar.ical$") + +func BuildCalendarICal() string { + defer CatchPanic() + return Url("/calendar.ical", nil) +} + // QUESTION(ben): Can we change these routes? var RegexLoginAction = regexp.MustCompile("^/login$") diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 6edf96b..7a27259 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "git.handmade.network/hmn/hmn/src/calendar" "git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -527,6 +528,16 @@ func EducationArticleToTemplate(a *models.EduArticle) EduArticle { return res } +func CalendarEventToTemplate(ev *calendar.CalendarEvent) CalendarEvent { + return CalendarEvent{ + Name: ev.Name, + Desc: ev.Desc, + StartTime: ev.StartTime.UTC(), + EndTime: ev.EndTime.UTC(), + CalName: ev.CalName, + } +} + func maybeString(s *string) string { if s == nil { return "" diff --git a/src/templates/src/calendar_index.html b/src/templates/src/calendar_index.html new file mode 100644 index 0000000..250fa9a --- /dev/null +++ b/src/templates/src/calendar_index.html @@ -0,0 +1,89 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Future events

+
+ {{ range .Calendars }} + + {{ end }} +
+
+ Copy iCal Url +
+
+ {{ range .Events }} +
+
{{ timehtmlcontent .StartTime }}
+
+ {{ .Name }} +
+ {{ with .Desc }} +
{{ . }}
+ {{ end }} + {{ with .CalName }} +
{{ . }}
+ {{ end }} +
+ {{ end }} +
+
+ +{{ end }} diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 751dfaa..d87aca9 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -91,6 +91,7 @@ Fishbowls Podcast Handmade Dev Show + Calendar
@@ -183,9 +184,18 @@ } } + const dtf = new Intl.DateTimeFormat([], { + dateStyle: "full", + timeStyle: "short", + }); + for (const time of document.querySelectorAll('time')) { const d = new Date(Date.parse(time.dateTime)); - time.title = d.toLocaleString(); + if (time.getAttribute("data-type") == "content") { + time.textContent = dtf.format(d); + } else { + time.title = dtf.format(d); + } } } }); diff --git a/src/templates/templates.go b/src/templates/templates.go index c399f2c..d82e2b2 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -252,6 +252,10 @@ var HMNTemplateFuncs = template.FuncMap{ iso := t.UTC().Format(time.RFC3339) return template.HTML(fmt.Sprintf(``, iso, formatted)) }, + "timehtmlcontent": func(t time.Time) template.HTML { + iso := t.UTC().Format(time.RFC3339) + return template.HTML(fmt.Sprintf(``, iso)) + }, "noescape": func(str string) template.HTML { return template.HTML(str) }, diff --git a/src/templates/types.go b/src/templates/types.go index d72acf0..dd717c2 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -55,6 +55,7 @@ type Header struct { ConferencesUrl string JamsUrl string EducationUrl string + CalendarUrl string Project *ProjectHeader } @@ -415,3 +416,11 @@ type EduArticle struct { Content template.HTML } + +type CalendarEvent struct { + Name string + Desc string + StartTime time.Time + EndTime time.Time + CalName string +} diff --git a/src/website/base_data.go b/src/website/base_data.go index c4949ab..3b7ce4a 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -79,6 +79,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc ConferencesUrl: hmnurl.BuildConferences(), JamsUrl: hmnurl.BuildJamsIndex(), EducationUrl: hmnurl.BuildEducationIndex(), + CalendarUrl: hmnurl.BuildCalendarIndex(), }, Footer: templates.Footer{ HomepageUrl: hmnurl.BuildHomepage(), diff --git a/src/website/calendar.go b/src/website/calendar.go new file mode 100644 index 0000000..c1dce60 --- /dev/null +++ b/src/website/calendar.go @@ -0,0 +1,55 @@ +package website + +import ( + "net/http" + + "git.handmade.network/hmn/hmn/src/calendar" + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/templates" +) + +func CalendarIndex(c *RequestContext) ResponseData { + type CalData struct { + templates.BaseData + Calendars []string + Events []templates.CalendarEvent + BaseICalUrl string + } + events := calendar.GetFutureEvents() + + templateEvents := make([]templates.CalendarEvent, 0, len(events)) + for _, ev := range events { + templateEvents = append(templateEvents, templates.CalendarEventToTemplate(&ev)) + } + + calNames := []string{} + for _, c := range config.Config.Calendars { + calNames = append(calNames, c.Name) + } + + calendarData := CalData{ + BaseData: getBaseDataAutocrumb(c, "Calendar"), + Calendars: calNames, + Events: templateEvents, + BaseICalUrl: hmnurl.BuildCalendarICal(), + } + var res ResponseData + res.MustWriteTemplate("calendar_index.html", calendarData, c.Perf) + return res +} + +func CalendarICal(c *RequestContext) ResponseData { + query := c.Req.URL.Query() + cals := make([]string, 0, len(query)) + for key := range query { + cals = append(cals, key) + } + calBytes, err := calendar.GetICal(cals) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, err) + } + var res ResponseData + res.Write(calBytes) + return res +} diff --git a/src/website/routes.go b/src/website/routes.go index ecc1f9c..76bad97 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -79,6 +79,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { hmnOnly.GET(hmnurl.RegexTimeMachineFormDone, needsAuth(TimeMachineFormDone)) hmnOnly.POST(hmnurl.RegexTimeMachineForm, needsAuth(csrfMiddleware(TimeMachineFormSubmit))) + hmnOnly.GET(hmnurl.RegexCalendarIndex, CalendarIndex) + hmnOnly.GET(hmnurl.RegexCalendarICal, CalendarICal) + hmnOnly.GET(hmnurl.RegexStaffRolesIndex, StaffRolesIndex) hmnOnly.GET(hmnurl.RegexStaffRole, StaffRole) diff --git a/src/website/website.go b/src/website/website.go index 80c4361..b411ef2 100644 --- a/src/website/website.go +++ b/src/website/website.go @@ -12,6 +12,7 @@ import ( "git.handmade.network/hmn/hmn/src/assets" "git.handmade.network/hmn/hmn/src/auth" + "git.handmade.network/hmn/hmn/src/calendar" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/discord" @@ -52,6 +53,7 @@ var WebsiteCommand = &cobra.Command{ twitch.MonitorTwitchSubscriptions(backgroundJobContext, conn), hmns3.StartServer(backgroundJobContext), assets.BackgroundPreviewGeneration(backgroundJobContext, conn), + calendar.MonitorCalendars(backgroundJobContext), ) signals := make(chan os.Signal, 1)