forked from hswaw/hscloud
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:
parent
50e987cb68
commit
8ef457feee
8 changed files with 525 additions and 0 deletions
29
hswaw/site/calendar/BUILD.bazel
Normal file
29
hswaw/site/calendar/BUILD.bazel
Normal 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"],
|
||||
)
|
123
hswaw/site/calendar/event.go
Normal file
123
hswaw/site/calendar/event.go
Normal 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
|
||||
}
|
73
hswaw/site/calendar/event_test.go
Normal file
73
hswaw/site/calendar/event_test.go
Normal 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
120
hswaw/site/calendar/load.go
Normal 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)
|
||||
}
|
51
hswaw/site/calendar/load_test.go
Normal file
51
hswaw/site/calendar/load_test.go
Normal 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)
|
||||
}
|
||||
}
|
49
hswaw/site/calendar/test.ical
Normal file
49
hswaw/site/calendar/test.ical
Normal 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
|
73
hswaw/site/calendar/time.go
Normal file
73
hswaw/site/calendar/time.go
Normal 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
|
||||
}
|
7
third_party/go/repositories.bzl
vendored
7
third_party/go/repositories.bzl
vendored
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue