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
diff --git a/hswaw/site/calendar/load.go b/hswaw/site/calendar/load.go
new file mode 100644
index 0000000..5ea9198
--- /dev/null
+++ b/hswaw/site/calendar/load.go
@@ -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)
+}