blob: 177ddc884cd531db5fcb73da50b029c333de1d74 [file] [log] [blame]
Serge Bazanski8ef457f2021-07-11 14:42:38 +00001package calendar
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "net/http"
8 "sort"
Serge Bazanski717aad42021-07-11 16:03:43 +00009 "strings"
Serge Bazanski8ef457f2021-07-11 14:42:38 +000010 "time"
11 _ "time/tzdata"
12
13 ics "github.com/arran4/golang-ical"
14 "github.com/golang/glog"
Serge Bazanski94d96492023-09-22 20:52:23 +000015 rrule "github.com/teambition/rrule-go"
Serge Bazanski8ef457f2021-07-11 14:42:38 +000016)
17
18const (
Serge Bazanski11b276d2021-07-11 23:49:55 +000019 // EventsURL is the calendar from which we load public Hackerspace events.
20 EventsURL = "https://owncloud.hackerspace.pl/remote.php/dav/public-calendars/g8toktZrA9fyAHNi/?export"
Serge Bazanski8ef457f2021-07-11 14:42:38 +000021)
22
23// eventsBySooner sorts upcoming events so the one that happens the soonest
24// will be first in the list.
25type eventBySooner []*UpcomingEvent
26
27func (e eventBySooner) Len() int { return len(e) }
28func (e eventBySooner) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
29func (e eventBySooner) Less(i, j int) bool {
30 a, b := e[i], e[j]
31 if a.Start.Time == b.Start.Time {
32 if a.End.Time == b.End.Time {
33 return a.UID < b.UID
34 }
35 return a.End.Time.Before(b.End.Time)
36 }
37 return a.Start.Time.Before(b.Start.Time)
38}
39
40// parseUpcomingEvents generates a list of upcoming events from an open ICS/iCal file.
41func parseUpcomingEvents(now time.Time, data io.Reader) ([]*UpcomingEvent, error) {
42 cal, err := ics.ParseCalendar(data)
43 if err != nil {
44 return nil, fmt.Errorf("ParseCalendar(%q): %w", err)
45 }
46
47 var out []*UpcomingEvent
48 for _, event := range cal.Events() {
49 uidProp := event.GetProperty(ics.ComponentPropertyUniqueId)
50 if uidProp == nil || uidProp.Value == "" {
51 glog.Errorf("Event with no UID, ignoring: %+v", event)
52 continue
53 }
54 uid := uidProp.Value
55
56 summaryProp := event.GetProperty(ics.ComponentPropertySummary)
57 if summaryProp == nil || summaryProp.Value == "" {
58 glog.Errorf("Event %s has no summary, ignoring", uid)
59 }
60 summary := summaryProp.Value
61
Serge Bazanski717aad42021-07-11 16:03:43 +000062 var description string
63 descriptionProp := event.GetProperty(ics.ComponentPropertyDescription)
64 if descriptionProp != nil && descriptionProp.Value != "" {
65 // The ICS/iCal description has escaped newlines. Undo that.
66 description = strings.ReplaceAll(descriptionProp.Value, `\n`, "\n")
67 }
68
Serge Bazanski8ef457f2021-07-11 14:42:38 +000069 status := event.GetProperty(ics.ComponentPropertyStatus)
70 tentative := false
71 if status != nil {
72 if status.Value == string(ics.ObjectStatusCancelled) {
73 // NextCloud only has CONFIRMED, CANCELELD and TENTATIVE for
74 // events. We drop everything CANCELELD and keep things that are
75 // TENTATIVE.
76 continue
77 }
78 if status.Value == string(ics.ObjectStatusTentative) {
79 tentative = true
80 }
81 }
82
83 start, err := parseICSTime(event.GetProperty(ics.ComponentPropertyDtStart))
84 if err != nil {
85 glog.Errorf("Event %s has unparseable DTSTART, ignoring: %v", uid, err)
86 continue
87 }
88 end, err := parseICSTime(event.GetProperty(ics.ComponentPropertyDtEnd))
89 if err != nil {
90 glog.Errorf("Event %s has unparseable DTEND, ignoring: %v", uid, err)
91 continue
92 }
93
94 if (start.WholeDay && !end.WholeDay) || (!start.WholeDay && end.WholeDay) {
95 glog.Errorf("Event %s has whole-day inconsistencies, start: %s, end: %s, ignoring", uid, start, end)
96 }
97
Serge Bazanski94d96492023-09-22 20:52:23 +000098 rruleS := event.GetProperty(ics.ComponentPropertyRrule)
99 if rruleS != nil {
100 rrule, err := rrule.StrToRRule(rruleS.Value)
101 if err != nil {
102 glog.Errorf("Event %s has unparseable RRULE, ignoring: %v", uid, err)
103 continue
104 }
105 rrule.DTStart(start.Time)
106
107 duration := end.Time.Sub(start.Time)
108 if start.WholeDay {
109 duration = time.Hour * 24
110 }
111
112 next := rrule.After(now, true)
113 if next.IsZero() {
114 continue
115 }
116 u := &UpcomingEvent{
117 UID: uid,
118 Summary: summary,
119 Description: description,
120 Start: &EventTime{
121 Time: next,
122 WholeDay: start.WholeDay,
123 },
124 End: &EventTime{
125 Time: next.Add(duration),
126 WholeDay: start.WholeDay,
127 },
128 Tentative: tentative,
129 }
130 out = append(out, u)
131 continue
132 }
133
Serge Bazanski8ef457f2021-07-11 14:42:38 +0000134 u := &UpcomingEvent{
Serge Bazanski717aad42021-07-11 16:03:43 +0000135 UID: uid,
136 Summary: summary,
137 Description: description,
138 Start: start,
139 End: end,
140 Tentative: tentative,
Serge Bazanski8ef457f2021-07-11 14:42:38 +0000141 }
142 if u.Elapsed(now) {
143 continue
144 }
145
146 out = append(out, u)
147 }
148 sort.Sort(eventBySooner(out))
149 return out, nil
150}
151
152// GetUpcomingEvents returns all public Hackerspace events that are upcoming
153// relative to the given time 'now' as per the Warsaw Hackerspace public
154// calender (from owncloud.hackerspace.pl).
155func GetUpcomingEvents(ctx context.Context, now time.Time) ([]*UpcomingEvent, error) {
Serge Bazanski11b276d2021-07-11 23:49:55 +0000156 r, err := http.NewRequestWithContext(ctx, "GET", EventsURL, nil)
Serge Bazanski8ef457f2021-07-11 14:42:38 +0000157 if err != nil {
Serge Bazanski11b276d2021-07-11 23:49:55 +0000158 return nil, fmt.Errorf("NewRequest(%q): %w", EventsURL, err)
Serge Bazanski8ef457f2021-07-11 14:42:38 +0000159 }
160 res, err := http.DefaultClient.Do(r)
161 if err != nil {
Serge Bazanski11b276d2021-07-11 23:49:55 +0000162 return nil, fmt.Errorf("Do(%q): %w", EventsURL, err)
Serge Bazanski8ef457f2021-07-11 14:42:38 +0000163 }
164 defer res.Body.Close()
165 return parseUpcomingEvents(now, res.Body)
166}