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",
+ )
+