hswaw/site: add calendar/event library

This will let us populate upcoming events server-side on the website (or
serve this data in a format that can be more easily consumed by JS).

Change-Id: I0f6b5bf9831f4d07acebb4eb77a7d88b63fe8e46
This commit is contained in:
q3k 2021-07-11 14:42:38 +00:00
parent 50e987cb68
commit 8ef457feee
8 changed files with 525 additions and 0 deletions

View file

@ -0,0 +1,29 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = [
"load.go",
"event.go",
"time.go",
],
importpath = "code.hackerspace.pl/hscloud/hswaw/site/calendar",
visibility = ["//visibility:private"],
deps = [
"@com_github_arran4_golang_ical//:go_default_library",
"@com_github_golang_glog//:go_default_library",
],
)
go_test(
name = "go_default_test",
srcs = [
"event_test.go",
"load_test.go",
],
data = [
":test.ical",
],
embed = [":go_default_library"],
deps = ["@com_github_google_go_cmp//cmp:go_default_library"],
)

View file

@ -0,0 +1,123 @@
package calendar
import (
"fmt"
"sync"
"time"
"github.com/golang/glog"
)
// UpcomingEvent is a calendar event that will happen in the near future, or is
// currently happening (relative to same arbitrary timestamp of 'now',
// depending on the way the UpcomingEvent is crated).
//
// It is a best-effort parse of an ICS/iCal event into some event that can be
// interpreted as a 'community event', to be displayed publicly on a site.
type UpcomingEvent struct {
// UID is the unique ICS/iCal ID of this event.
UID string
// Summary is the 'title' of the event, usually a short one-liner.
Summary string
// Start and End of the events, potentially whole-day dates. See EventTime
// for more information.
// If Start is WholeDay then so is End, and vice-versa.
Start *EventTime
// End of the event, exclusive of the time range (ie. if a timestamp it
// defines the timestamp at which the next event can start; if it's whole
// day it defines the first day on which the event does not take place).
End *EventTime
// Tentative is whether this event is marked as 'Tentative' in the source
// calendar.
Tentative bool
}
// WholeDay returns true if this is a whole-day (or multi-day) event.
func (u *UpcomingEvent) WholeDay() bool {
return u.Start.WholeDay
}
var (
// onceComplainWarsawGone gates throwing a very verbose message about being
// unable to localize UpcomingEvents into Warsaw local time by WarsawDate.
onceComplainWarsawGone sync.Once
)
// WarsawDate prints a human-readable timestamp that makes sense within the
// context of this event taking place in Warsaw, or at least in the same
// timezone as Warsaw.
// It will return a time in one of the following formats:
//
// YEAR/MONTH/DAY
// (For one-day events)
//
// YEAR/MONTH/DAY - DAY
// (For multi-day events within the same month)
//
// YEAR/MONTH/DAY - YEAR/MONTH/DAY
// (For multi-day events spanning more than one month)
//
// YEAR/MONTH/DAY HH:MM - HH:MM
// (For timestamped events within the same day)
//
// YEAR/MONTH/DAY HH:MM - YEAR/MONTH/DAY HH:MM
// (For timestamped events spanning more than one day)
//
func (u *UpcomingEvent) WarsawDate() string {
YM := "2006/01"
D := "02"
YMD := "2006/01/02"
HM := "15:04"
YMDHM := "2006/01/02 15:04"
if u.WholeDay() {
start := u.Start.Time
// ICS whole-day dates are [start, end), ie. 'end' is exclusive.
end := u.End.Time.AddDate(0, 0, -1)
if start == end {
// Event is one-day.
return start.Format(YMD)
}
if start.Year() == end.Year() && start.Month() == end.Month() {
// Event starts and ends on the same month, print shortened form.
return fmt.Sprintf("%s/%s - %s", start.Format(YM), start.Format(D), end.Format(D))
}
// Event spans multiple months, print full form.
return fmt.Sprintf("%s - %s", start.Format(YMD), end.Format(YMD))
}
warsaw, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
onceComplainWarsawGone.Do(func() {
glog.Errorf("Could not load Europe/Warsaw timezone, did the city cease to exist? LoadLoaction: %v", err)
})
// Even in the face of a cataclysm, degrade gracefully and assume the
// users are local to this service's timezone.
warsaw = time.Local
}
start := u.Start.Time.In(warsaw)
end := u.End.Time.In(warsaw)
if start.Year() == end.Year() && start.Month() == end.Month() && start.Day() == end.Day() {
// Event starts and ends on same day, print shortened form.
return fmt.Sprintf("%s %s - %s", start.Format(YMD), start.Format(HM), end.Format(HM))
}
// Event spans multiple days, print full form.
return fmt.Sprintf("%s - %s", start.Format(YMDHM), end.Format(YMDHM))
}
func (u *UpcomingEvent) String() string {
return fmt.Sprintf("%s (%s)", u.Summary, u.WarsawDate())
}
func (e *UpcomingEvent) Elapsed(t time.Time) bool {
// Event hasn't started yet?
if e.Start.Time.After(t) {
return false
}
// Event has started, but hasn't ended?
if e.End.Time.After(t) {
return false
}
return true
}

View file

@ -0,0 +1,73 @@
package calendar
import (
"fmt"
"testing"
"time"
)
func TestWarsawDate(t *testing.T) {
makeTime := func(s string) EventTime {
t.Helper()
warsaw, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
t.Fatalf("could not get Warsaw timezone: %v", err)
}
ti, err := time.ParseInLocation("2006/01/02 15:04", s, warsaw)
if err != nil {
t.Fatal("could not parse test time %q: %v", s, err)
}
return EventTime{
Time: ti,
}
}
makeDay := func(s string) EventTime {
t.Helper()
ti, err := time.Parse("2006/01/02", s)
if err != nil {
t.Fatal("could not parse test day %q: %v", s, err)
}
return EventTime{
Time: ti,
WholeDay: true,
}
}
for i, te := range []struct {
start EventTime
end EventTime
want string
}{
{
makeTime("2021/03/14 13:37"), makeTime("2021/04/20 21:37"),
"2021/03/14 13:37 - 2021/04/20 21:37",
},
{
makeTime("2021/04/20 13:37"), makeTime("2021/04/20 21:37"),
"2021/04/20 13:37 - 21:37",
},
{
makeDay("2021/06/01"), makeDay("2021/07/01"),
"2021/06/01 - 30",
},
{
makeDay("2021/03/14"), makeDay("2021/04/21"),
"2021/03/14 - 2021/04/20",
},
{
makeDay("2021/04/20"), makeDay("2021/04/21"),
"2021/04/20",
},
} {
te := te
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
ev := UpcomingEvent{
Start: &te.start,
End: &te.end,
}
got := ev.WarsawDate()
if got != te.want {
t.Fatalf("wanted %q, got %q", te.want, got)
}
})
}
}

120
hswaw/site/calendar/load.go Normal file
View file

@ -0,0 +1,120 @@
package calendar
import (
"context"
"fmt"
"io"
"net/http"
"sort"
"time"
_ "time/tzdata"
ics "github.com/arran4/golang-ical"
"github.com/golang/glog"
)
const (
// eventsURL is the calendar from which we load public Hackerspace events.
eventsURL = "https://owncloud.hackerspace.pl/remote.php/dav/public-calendars/g8toktZrA9fyAHNi/?export"
)
// eventsBySooner sorts upcoming events so the one that happens the soonest
// will be first in the list.
type eventBySooner []*UpcomingEvent
func (e eventBySooner) Len() int { return len(e) }
func (e eventBySooner) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
func (e eventBySooner) Less(i, j int) bool {
a, b := e[i], e[j]
if a.Start.Time == b.Start.Time {
if a.End.Time == b.End.Time {
return a.UID < b.UID
}
return a.End.Time.Before(b.End.Time)
}
return a.Start.Time.Before(b.Start.Time)
}
// parseUpcomingEvents generates a list of upcoming events from an open ICS/iCal file.
func parseUpcomingEvents(now time.Time, data io.Reader) ([]*UpcomingEvent, error) {
cal, err := ics.ParseCalendar(data)
if err != nil {
return nil, fmt.Errorf("ParseCalendar(%q): %w", err)
}
var out []*UpcomingEvent
for _, event := range cal.Events() {
uidProp := event.GetProperty(ics.ComponentPropertyUniqueId)
if uidProp == nil || uidProp.Value == "" {
glog.Errorf("Event with no UID, ignoring: %+v", event)
continue
}
uid := uidProp.Value
summaryProp := event.GetProperty(ics.ComponentPropertySummary)
if summaryProp == nil || summaryProp.Value == "" {
glog.Errorf("Event %s has no summary, ignoring", uid)
}
summary := summaryProp.Value
status := event.GetProperty(ics.ComponentPropertyStatus)
tentative := false
if status != nil {
if status.Value == string(ics.ObjectStatusCancelled) {
// NextCloud only has CONFIRMED, CANCELELD and TENTATIVE for
// events. We drop everything CANCELELD and keep things that are
// TENTATIVE.
continue
}
if status.Value == string(ics.ObjectStatusTentative) {
tentative = true
}
}
start, err := parseICSTime(event.GetProperty(ics.ComponentPropertyDtStart))
if err != nil {
glog.Errorf("Event %s has unparseable DTSTART, ignoring: %v", uid, err)
continue
}
end, err := parseICSTime(event.GetProperty(ics.ComponentPropertyDtEnd))
if err != nil {
glog.Errorf("Event %s has unparseable DTEND, ignoring: %v", uid, err)
continue
}
if (start.WholeDay && !end.WholeDay) || (!start.WholeDay && end.WholeDay) {
glog.Errorf("Event %s has whole-day inconsistencies, start: %s, end: %s, ignoring", uid, start, end)
}
u := &UpcomingEvent{
UID: uid,
Summary: summary,
Start: start,
End: end,
Tentative: tentative,
}
if u.Elapsed(now) {
continue
}
out = append(out, u)
}
sort.Sort(eventBySooner(out))
return out, nil
}
// GetUpcomingEvents returns all public Hackerspace events that are upcoming
// relative to the given time 'now' as per the Warsaw Hackerspace public
// calender (from owncloud.hackerspace.pl).
func GetUpcomingEvents(ctx context.Context, now time.Time) ([]*UpcomingEvent, error) {
r, err := http.NewRequestWithContext(ctx, "GET", eventsURL, nil)
if err != nil {
return nil, fmt.Errorf("NewRequest(%q): %w", eventsURL, err)
}
res, err := http.DefaultClient.Do(r)
if err != nil {
return nil, fmt.Errorf("Do(%q): %w", eventsURL, err)
}
defer res.Body.Close()
return parseUpcomingEvents(now, res.Body)
}

View file

@ -0,0 +1,51 @@
package calendar
import (
"os"
"testing"
"time"
"github.com/google/go-cmp/cmp"
)
func TestUpcomingEvents(t *testing.T) {
r, err := os.Open("test.ical")
if err != nil {
t.Fatalf("Could not open test ical: %v", err)
}
ti := time.Unix(1626011785, 0)
events, err := parseUpcomingEvents(ti, r)
if err != nil {
t.Fatalf("getUpcomingEvents: %v", err)
}
want := []*UpcomingEvent{
{
UID: "65cd51ba-2fd7-475e-a274-61d19c186b66",
Summary: "test event please ignore",
Start: &EventTime{
Time: time.Unix(1626091200, 0),
},
End: &EventTime{
Time: time.Unix(1626093000, 0),
},
},
{
UID: "2f874784-1e09-4cdc-8ae6-185c9ee36be0",
Summary: "many days",
Start: &EventTime{
Time: time.Unix(1626134400, 0),
WholeDay: true,
},
End: &EventTime{
Time: time.Unix(1626393600, 0),
WholeDay: true,
},
},
}
if diff := cmp.Diff(events, want); diff != "" {
t.Errorf("%s", diff)
}
}

View file

@ -0,0 +1,49 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:q3k test calendar (cc161907-84ed-42b3-b65f-8bdc79161ffe)
X-APPLE-CALENDAR-COLOR:#1E78C1
REFRESH-INTERVAL;VALUE=DURATION:PT4H
X-PUBLISHED-TTL:PT4H
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20210711T134245Z
DTSTAMP:20210711T134342Z
LAST-MODIFIED:20210711T134342Z
SEQUENCE:3
UID:2f874784-1e09-4cdc-8ae6-185c9ee36be0
DTSTART;VALUE=DATE:20210713
DTEND;VALUE=DATE:20210716
SUMMARY:many days
DESCRIPTION:I am a multiline\n\ndescription\n\nwith a link: https://example
.com/foo\n\nbarfoo
END:VEVENT
BEGIN:VEVENT
CREATED:20210711T134220Z
DTSTAMP:20210711T134323Z
LAST-MODIFIED:20210711T134323Z
SEQUENCE:3
UID:65cd51ba-2fd7-475e-a274-61d19c186b66
DTSTART;TZID=Europe/Berlin:20210712T140000
DTEND;TZID=Europe/Berlin:20210712T143000
SUMMARY:test event please ignore
DESCRIPTION:I am a description
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,73 @@
package calendar
import (
"fmt"
"time"
ics "github.com/arran4/golang-ical"
)
// EventTime is a timestamp for calendar events. It either represents a real
// point-in time or a calender day, if it's a whole-day event.
type EventTime struct {
// Time is a timestamp in the timezone originally defined for this event if
// WholeDay is true. Otherwise, it's a UTC time from which a year, month
// and day can be extracted and treated as the indication of a 'calendar
// day' in an unknown timezone.
Time time.Time
// WholeDay is true if this EventTime represents an entire calendar day.
WholeDay bool
}
func (e *EventTime) String() string {
if e.WholeDay {
return fmt.Sprintf("%s (whole day)", e.Time.Format("2006/01/02"))
} else {
return e.Time.String()
}
}
// parseICSTime attempts to parse a given ICS DT{START,END} object into an
// EventTime, trying to figure out if the given object represents a timestamp
// or a whole-day event.
func parseICSTime(p *ics.IANAProperty) (*EventTime, error) {
// If this is has a VALUE of DATE, then this is a whole-day time.
// Otherwise, it's an actual timestamp.
valueList, ok := p.ICalParameters[string(ics.ParameterValue)]
if ok {
if len(valueList) != 1 || valueList[0] != "DATE" {
return nil, fmt.Errorf("unsupported time type: %v", valueList)
}
ts, err := time.Parse("20060102", p.Value)
if err != nil {
return nil, fmt.Errorf("could not parse date %q: %w", p.Value, err)
}
return &EventTime{
Time: ts,
WholeDay: true,
}, nil
}
// You would expect that nextcloud would emit VALUE == DATE-TIME for
// timestamps, but that just doesn't seem to be the case. Maye I should
// read the ICS standard...
tzidList, ok := p.ICalParameters[string(ics.ParameterTzid)]
if !ok || len(tzidList) != 1 {
return nil, fmt.Errorf("TZID missing")
}
tzid := tzidList[0]
location, err := time.LoadLocation(tzid)
if err != nil {
return nil, fmt.Errorf("could not parse TZID %q: %w", tzid, err)
}
ts, err := time.ParseInLocation("20060102T150405", p.Value, location)
if err != nil {
return nil, fmt.Errorf("could not parse time %q: %w", p.Value, err)
}
return &EventTime{
Time: ts,
WholeDay: false,
}, nil
}

View file

@ -1844,3 +1844,10 @@ def go_repositories():
sum = "h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=",
version = "v1.1.1",
)
go_repository(
name = "com_github_arran4_golang_ical",
importpath = "github.com/arran4/golang-ical",
sum = "h1:oOgavmDMGCnNtwZwNoXuK3jCcpF3I96Do9/5qPeSCr8=",
version = "v0.0.0-20210601225245-48fd351b08e7",
)