feat: serve a subscribable ICS calendar feed

This commit is contained in:
Jakub Zych
2026-10-02 17:38:48 +02:00
parent bccb747171
commit 90247a460d
6 changed files with 766 additions and 1 deletions

235
classes/calendar.go Normal file
View 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
View 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
}