feat: serve a subscribable ICS calendar feed
This commit is contained in:
235
classes/calendar.go
Normal file
235
classes/calendar.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
)
|
||||
|
||||
// CalendarUIDDomain is the right-hand side of every UID in the feed. It
|
||||
// stays fixed, whatever host serves the feed, so a calendar that has
|
||||
// subscribed keeps matching its events.
|
||||
const CalendarUIDDomain = "bm.jakubzych.com"
|
||||
|
||||
// CalendarName is the X-WR-CALNAME of the feed.
|
||||
const CalendarName = "BM UWr – plan zajęć"
|
||||
|
||||
// CalendarOptions selects what a feed lists.
|
||||
type CalendarOptions struct {
|
||||
// ExcludedCourseIDs are left out, with their sessions and deadlines.
|
||||
ExcludedCourseIDs []uint
|
||||
// IncludeMeetings adds the meetings as all-day events.
|
||||
IncludeMeetings bool
|
||||
// IncludeDeadlines adds the deadlines.
|
||||
IncludeDeadlines bool
|
||||
// SiteURL is the student app's base URL (no trailing slash), used for the
|
||||
// course links in the descriptions. Empty leaves the links out.
|
||||
SiteURL string
|
||||
}
|
||||
|
||||
// CalendarData is what a feed is built from. Sessions need their Course and
|
||||
// Meeting loaded, deadlines their Course.
|
||||
type CalendarData struct {
|
||||
Sessions []models.ClassSession
|
||||
Meetings []models.Meeting
|
||||
Deadlines []models.Deadline
|
||||
}
|
||||
|
||||
// formLabels are the Polish names of the course forms in a summary. "inne"
|
||||
// has none: the course name says enough.
|
||||
var formLabels = map[string]string{
|
||||
models.FormWyklad: "wykład",
|
||||
models.FormCwiczenia: "ćwiczenia",
|
||||
models.FormKonwersatorium: "konwersatorium",
|
||||
models.FormSeminarium: "seminarium",
|
||||
}
|
||||
|
||||
// deadlineLead is how long before its due time a deadline event starts:
|
||||
// most calendars show a zero-length event badly.
|
||||
const deadlineLead = 30 * time.Minute
|
||||
|
||||
// BuildCalendar writes the feed: a VCALENDAR with the class sessions of the
|
||||
// courses not excluded, then the meetings and the deadlines when included.
|
||||
// Times are written in UTC, so no VTIMEZONE is needed.
|
||||
func BuildCalendar(data CalendarData, opt CalendarOptions) []byte {
|
||||
excluded := make(map[uint]bool, len(opt.ExcludedCourseIDs))
|
||||
for _, id := range opt.ExcludedCourseIDs {
|
||||
excluded[id] = true
|
||||
}
|
||||
var w ICS
|
||||
w.Begin("VCALENDAR")
|
||||
w.Prop("PRODID", "-//jakubzych//BM UWr//PL")
|
||||
w.Prop("VERSION", "2.0")
|
||||
w.Prop("CALSCALE", "GREGORIAN")
|
||||
w.Prop("METHOD", "PUBLISH")
|
||||
w.Text("X-WR-CALNAME", CalendarName)
|
||||
w.Prop("X-WR-TIMEZONE", "Europe/Warsaw")
|
||||
w.Prop("REFRESH-INTERVAL;VALUE=DURATION", "PT6H")
|
||||
w.Prop("X-PUBLISHED-TTL", "PT6H")
|
||||
for _, s := range data.Sessions {
|
||||
if excluded[s.CourseID] {
|
||||
continue
|
||||
}
|
||||
writeSession(&w, s, opt.SiteURL)
|
||||
}
|
||||
if opt.IncludeMeetings {
|
||||
for _, m := range data.Meetings {
|
||||
writeMeeting(&w, m)
|
||||
}
|
||||
}
|
||||
if opt.IncludeDeadlines {
|
||||
for _, d := range data.Deadlines {
|
||||
if d.CourseID != nil && excluded[*d.CourseID] {
|
||||
continue
|
||||
}
|
||||
writeDeadline(&w, d)
|
||||
}
|
||||
}
|
||||
w.End("VCALENDAR")
|
||||
return w.Bytes()
|
||||
}
|
||||
|
||||
func uid(kind string, id uint) string {
|
||||
return kind + "-" + strconv.FormatUint(uint64(id), 10) + "@" + CalendarUIDDomain
|
||||
}
|
||||
|
||||
// stamp writes DTSTAMP and LAST-MODIFIED from the row's update time, so an
|
||||
// unchanged row gives the same bytes on every fetch.
|
||||
func stamp(w *ICS, updated time.Time) {
|
||||
if updated.IsZero() {
|
||||
updated = time.Unix(0, 0)
|
||||
}
|
||||
w.UTC("DTSTAMP", updated)
|
||||
w.UTC("LAST-MODIFIED", updated)
|
||||
}
|
||||
|
||||
// SessionSummary is the title of a class session: the course name, the form
|
||||
// in Polish (none for "inne") and " (online)" for an online session.
|
||||
func SessionSummary(s models.ClassSession) string {
|
||||
var b strings.Builder
|
||||
if s.Course != nil {
|
||||
b.WriteString(s.Course.Name)
|
||||
if label := formLabels[s.Course.Form]; label != "" {
|
||||
b.WriteString(" – ")
|
||||
b.WriteString(label)
|
||||
}
|
||||
} else {
|
||||
b.WriteString("Zajęcia")
|
||||
}
|
||||
if s.IsOnline {
|
||||
b.WriteString(" (online)")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeSession(w *ICS, s models.ClassSession, siteURL string) {
|
||||
w.Begin("VEVENT")
|
||||
w.Prop("UID", uid("session", s.ID))
|
||||
stamp(w, s.UpdatedAt)
|
||||
w.Prop("SEQUENCE", "0")
|
||||
w.UTC("DTSTART", s.StartsAt.MustTime())
|
||||
w.UTC("DTEND", s.EndsAt.MustTime())
|
||||
w.Text("SUMMARY", SessionSummary(s))
|
||||
switch {
|
||||
case s.IsOnline:
|
||||
w.Text("LOCATION", "online")
|
||||
case s.Room != nil && strings.TrimSpace(*s.Room) != "":
|
||||
w.Text("LOCATION", "Koszarowa 3, s. "+strings.TrimSpace(*s.Room)+" (bud. 21 ISMiB)")
|
||||
}
|
||||
var lines []string
|
||||
if s.Course != nil && s.Course.Lecturer != nil && *s.Course.Lecturer != "" {
|
||||
lines = append(lines, "Prowadzący: "+*s.Course.Lecturer)
|
||||
}
|
||||
if s.Meeting != nil {
|
||||
lines = append(lines, "Zjazd "+strconv.Itoa(s.Meeting.Number))
|
||||
}
|
||||
if s.Notes != nil && strings.TrimSpace(*s.Notes) != "" {
|
||||
lines = append(lines, strings.TrimSpace(*s.Notes))
|
||||
}
|
||||
if s.Course != nil && s.Course.ElectiveGroup != nil {
|
||||
lines = append(lines, "Przedmiot do wyboru")
|
||||
}
|
||||
if siteURL != "" {
|
||||
link := siteURL + "/przedmioty/" + strconv.FormatUint(uint64(s.CourseID), 10)
|
||||
lines = append(lines, link)
|
||||
w.Prop("URL", link)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
w.Text("DESCRIPTION", strings.Join(lines, "\n"))
|
||||
}
|
||||
w.Prop("STATUS", "CONFIRMED")
|
||||
w.End("VEVENT")
|
||||
}
|
||||
|
||||
// MeetingSummary is the title of a meeting: "3. zjazd (stacjonarny)", with
|
||||
// the mode read from the start of the notes.
|
||||
func MeetingSummary(m models.Meeting) string {
|
||||
out := strconv.Itoa(m.Number) + ". zjazd"
|
||||
if m.Notes != nil {
|
||||
notes := strings.ToLower(strings.TrimSpace(*m.Notes))
|
||||
switch {
|
||||
case strings.HasPrefix(notes, "online"):
|
||||
out += " (online)"
|
||||
case strings.HasPrefix(notes, "stacjonarn"):
|
||||
out += " (stacjonarny)"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeMeeting(w *ICS, m models.Meeting) {
|
||||
start, err := m.StartsOn.Time()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
end, err := m.EndsOn.Time()
|
||||
if err != nil || end.Before(start) {
|
||||
end = start
|
||||
}
|
||||
w.Begin("VEVENT")
|
||||
w.Prop("UID", uid("meeting", m.ID))
|
||||
stamp(w, m.UpdatedAt)
|
||||
w.Prop("SEQUENCE", "0")
|
||||
w.Date("DTSTART", start)
|
||||
// DTEND of an all-day event is exclusive: the day after the last day.
|
||||
w.Date("DTEND", end.AddDate(0, 0, 1))
|
||||
w.Text("SUMMARY", MeetingSummary(m))
|
||||
if m.Notes != nil && strings.TrimSpace(*m.Notes) != "" {
|
||||
w.Text("DESCRIPTION", strings.TrimSpace(*m.Notes))
|
||||
}
|
||||
w.Prop("TRANSP", "TRANSPARENT")
|
||||
w.End("VEVENT")
|
||||
}
|
||||
|
||||
func writeDeadline(w *ICS, d models.Deadline) {
|
||||
due, err := d.DueAt.Time()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
summary := "Termin: " + d.Title
|
||||
w.Begin("VEVENT")
|
||||
w.Prop("UID", uid("deadline", d.ID))
|
||||
stamp(w, d.UpdatedAt)
|
||||
w.Prop("SEQUENCE", "0")
|
||||
w.UTC("DTSTART", due.Add(-deadlineLead))
|
||||
w.UTC("DTEND", due)
|
||||
w.Text("SUMMARY", summary)
|
||||
var lines []string
|
||||
if d.Course != nil {
|
||||
lines = append(lines, "Przedmiot: "+d.Course.Name)
|
||||
}
|
||||
if d.Description != nil && strings.TrimSpace(*d.Description) != "" {
|
||||
lines = append(lines, strings.TrimSpace(*d.Description))
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
w.Text("DESCRIPTION", strings.Join(lines, "\n"))
|
||||
}
|
||||
w.Begin("VALARM")
|
||||
w.Prop("ACTION", "DISPLAY")
|
||||
w.Text("DESCRIPTION", summary)
|
||||
w.Prop("TRIGGER", "-P1D")
|
||||
w.End("VALARM")
|
||||
w.End("VEVENT")
|
||||
}
|
||||
105
classes/ics.go
Normal file
105
classes/ics.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ICS writes an iCalendar (RFC 5545) stream: CRLF line endings, content
|
||||
// lines folded at 75 octets without splitting a UTF-8 sequence, and TEXT
|
||||
// values escaped. The zero value is ready to use.
|
||||
type ICS struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
// icsLineOctets is the longest content line RFC 5545 allows, not counting
|
||||
// the CRLF.
|
||||
const icsLineOctets = 75
|
||||
|
||||
// Begin writes BEGIN:<component>.
|
||||
func (w *ICS) Begin(component string) { w.Line("BEGIN:" + component) }
|
||||
|
||||
// End writes END:<component>.
|
||||
func (w *ICS) End(component string) { w.Line("END:" + component) }
|
||||
|
||||
// Prop writes name:value with the value as it is (a value of a type that
|
||||
// needs no escaping, such as a date, a duration or a URI).
|
||||
func (w *ICS) Prop(name, value string) { w.Line(name + ":" + value) }
|
||||
|
||||
// Text writes name:value with the value escaped as TEXT.
|
||||
func (w *ICS) Text(name, value string) { w.Line(name + ":" + EscapeText(value)) }
|
||||
|
||||
// UTC writes name:<t in UTC>, such as DTSTART:20261003T060000Z.
|
||||
func (w *ICS) UTC(name string, t time.Time) { w.Prop(name, FormatUTC(t)) }
|
||||
|
||||
// Date writes name;VALUE=DATE:<YYYYMMDD> for an all-day value.
|
||||
func (w *ICS) Date(name string, t time.Time) { w.Prop(name+";VALUE=DATE", t.Format("20060102")) }
|
||||
|
||||
// Line writes one content line, folded.
|
||||
func (w *ICS) Line(line string) {
|
||||
line = strings.NewReplacer("\r", "", "\n", "").Replace(line)
|
||||
w.buf.WriteString(FoldLine(line))
|
||||
w.buf.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Bytes returns the stream written so far.
|
||||
func (w *ICS) Bytes() []byte { return w.buf.Bytes() }
|
||||
|
||||
// FormatUTC formats t as an RFC 5545 UTC date-time (20261003T060000Z).
|
||||
func FormatUTC(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||
|
||||
// EscapeText escapes a TEXT value: backslash, semicolon, comma and line
|
||||
// breaks (as \n). A lone CR is dropped.
|
||||
func EscapeText(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case ';':
|
||||
b.WriteString(`\;`)
|
||||
case ',':
|
||||
b.WriteString(`\,`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FoldLine folds a content line (without its CRLF) so that no physical line
|
||||
// is longer than 75 octets. Continuation lines start with one space, which
|
||||
// counts towards their 75 octets; a multi-octet UTF-8 character is never
|
||||
// split.
|
||||
func FoldLine(line string) string {
|
||||
if len(line) <= icsLineOctets {
|
||||
return line
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for i := 0; i < len(line); {
|
||||
size := runeBytes(line[i:])
|
||||
if n+size > icsLineOctets {
|
||||
b.WriteString("\r\n ")
|
||||
n = 1
|
||||
}
|
||||
b.WriteString(line[i : i+size])
|
||||
n += size
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runeBytes is the length in bytes of the first character of s, also for an
|
||||
// invalid sequence (one byte).
|
||||
func runeBytes(s string) int {
|
||||
_, size := utf8.DecodeRuneInString(s)
|
||||
return size
|
||||
}
|
||||
Reference in New Issue
Block a user