Added calendars

This commit is contained in:
Asaf Gartner 2024-01-28 19:12:59 +02:00
parent 76be9b668a
commit 8bc4b5a66c
15 changed files with 591 additions and 1 deletions

2
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

378
src/calendar/calendar.go Normal file
View File

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

View File

@ -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"},

View File

@ -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

View File

@ -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$")

View File

@ -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 ""

View File

@ -0,0 +1,89 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="ph3 ph0-ns">
<h2>Future events</h2>
<div class="cal_toggles mb2 flex flex-row g2">
{{ range .Calendars }}
<label class="db br3 pv1 ph2 pointer b--gray ba" for="{{ . }}">
<input id="{{ . }}" autocomplete="off" type="checkbox" value="{{ . }}" checked />
<span>{{ . }}</span>
</label>
{{ end }}
</div>
<div class="">
<a class="ical_link" href="{{ .BaseICalUrl }}">Copy iCal Url</a>
</div>
<div class="flex flex-column g2">
{{ range .Events }}
<div data-calname="{{ .CalName }}" class="cal_event timeline-item pa3 br3">
<div>{{ timehtmlcontent .StartTime }}</div>
<div>
<strong class="f4 c--theme">{{ .Name }}</strong>
</div>
{{ with .Desc }}
<div class="mb2">{{ . }}</div>
{{ end }}
{{ with .CalName }}
<div class="dib br2 ba b--gray ph1 f7">{{ . }}</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
<script>
let events = document.querySelectorAll(".cal_event");
let toggles = document.querySelectorAll(".cal_toggles input");
let icalLink = document.querySelector(".ical_link");
let baseICalUrl = icalLink.href;
function refreshEvents() {
let cals = {};
for (let i = 0; i < toggles.length; ++i) {
cals[toggles[i].id] = toggles[i].checked;
}
for (let i = 0; i < events.length; ++i) {
let ev = events[i];
let calName = ev.getAttribute("data-calname");
if (cals[calName]) {
ev.style.display = "block";
} else {
ev.style.display = "none";
}
}
let icalFilter = [];
let hasAll = true;
for (let i = 0; i < toggles.length; ++i) {
if (toggles[i].checked) {
icalFilter.push(toggles[i].id);
} else {
hasAll = false;
}
}
if (hasAll) {
icalLink.href = baseICalUrl;
} else {
icalFilter.sort();
let url = new URL(baseICalUrl);
let params = new URLSearchParams();
for (let i = 0; i < icalFilter.length; ++i) {
params.append(icalFilter[i], "true");
}
url.search = params.toString();
icalLink.href = url.toString();
}
}
for (let i = 0; i < toggles.length; ++i) {
toggles[i].addEventListener("input", refreshEvents);
}
icalLink.addEventListener("click", function(ev) {
ev.preventDefault();
navigator.clipboard.writeText(icalLink.href);
});
</script>
{{ end }}

View File

@ -91,6 +91,7 @@
<a href="{{ .Header.FishbowlUrl }}">Fishbowls</a>
<a href="{{ .Header.PodcastUrl }}">Podcast</a>
<a href="https://guide.handmade-seattle.com/s" target="_blank">Handmade Dev Show</a>
<a href="{{ .Header.CalendarUrl }}">Calendar</a>
</div>
</div>
<div class="root-item">
@ -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);
}
}
}
});

View File

@ -252,6 +252,10 @@ var HMNTemplateFuncs = template.FuncMap{
iso := t.UTC().Format(time.RFC3339)
return template.HTML(fmt.Sprintf(`<time datetime="%s">%s</time>`, iso, formatted))
},
"timehtmlcontent": func(t time.Time) template.HTML {
iso := t.UTC().Format(time.RFC3339)
return template.HTML(fmt.Sprintf(`<time data-type="content" datetime="%s"></time>`, iso))
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},

View File

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

View File

@ -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(),

55
src/website/calendar.go Normal file
View File

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

View File

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

View File

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