Added calendars
This commit is contained in:
parent
76be9b668a
commit
8bc4b5a66c
2
go.mod
2
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
|
||||
|
|
4
go.sum
4
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=
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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$")
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -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 }}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue