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") }