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).
237 lines
6.7 KiB
Go
237 lines
6.7 KiB
Go
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")
|
||
}
|