Files
sm-bm-plugin/classes/calendar.go
Jakub Zych 71b848a376 Adopt SummerCMS v0.1.1 admin features
Datepickers on native time.Time / lagoon.Date fields, file uploads on
materials, readings and class sessions managed from the course form
(course_id made nullable).
2026-10-03 06:03:34 +02:00

237 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 s.CourseID == nil || 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)
w.UTC("DTEND", s.EndsAt)
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) {
if m.StartsOn.IsZero() {
return
}
// Midnight UTC keeps the calendar date for w.Date.
start := m.StartsOn.Time(nil)
end := m.EndsOn.Time(nil)
if m.EndsOn.IsZero() || 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 := d.DueAt
if due.IsZero() {
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")
}