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/BUILD.bazel b/hswaw/site/calendar/BUILD.bazel
new file mode 100644
index 0000000..297fde3
--- /dev/null
+++ b/hswaw/site/calendar/BUILD.bazel
@@ -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"],
+)
diff --git a/hswaw/site/calendar/event.go b/hswaw/site/calendar/event.go
new file mode 100644
index 0000000..19a916b
--- /dev/null
+++ b/hswaw/site/calendar/event.go
@@ -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
+}
diff --git a/hswaw/site/calendar/event_test.go b/hswaw/site/calendar/event_test.go
new file mode 100644
index 0000000..1e95306
--- /dev/null
+++ b/hswaw/site/calendar/event_test.go
@@ -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)
+			}
+		})
+	}
+}
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)
+}
diff --git a/hswaw/site/calendar/load_test.go b/hswaw/site/calendar/load_test.go
new file mode 100644
index 0000000..a07f134
--- /dev/null
+++ b/hswaw/site/calendar/load_test.go
@@ -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)
+	}
+}
diff --git a/hswaw/site/calendar/test.ical b/hswaw/site/calendar/test.ical
new file mode 100644
index 0000000..1d5908d
--- /dev/null
+++ b/hswaw/site/calendar/test.ical
@@ -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

diff --git a/hswaw/site/calendar/time.go b/hswaw/site/calendar/time.go
new file mode 100644
index 0000000..f742a67
--- /dev/null
+++ b/hswaw/site/calendar/time.go
@@ -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
+}
diff --git a/third_party/go/repositories.bzl b/third_party/go/repositories.bzl
index 5f6f8d9..12edfe1 100644
--- a/third_party/go/repositories.bzl
+++ b/third_party/go/repositories.bzl
@@ -1844,3 +1844,10 @@
         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",
+    )
+