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/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
+}