feat: usos sign-in, private calendar feed and timetable archive
This commit is contained in:
commit
7d4e21f977
33 files changed
+3270
No files matched your search
@@ -0,0 +1,114 @@
|
||||
package ical
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"usoscal/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
stamp = "20060102T150405Z"
|
||||
lineLimit = 75
|
||||
)
|
||||
|
||||
func Summary(event store.Event) string {
|
||||
if event.Kind == "" || event.Kind == event.Title {
|
||||
return event.Title
|
||||
}
|
||||
return event.Title + " (" + event.Kind + ")"
|
||||
}
|
||||
|
||||
func Location(event store.Event) string {
|
||||
place := event.Room
|
||||
switch {
|
||||
case place != "" && event.Building != "":
|
||||
place += " (" + event.Building + ")"
|
||||
case place == "":
|
||||
place = event.Building
|
||||
}
|
||||
return joinNonEmpty(", ", place, event.Address)
|
||||
}
|
||||
|
||||
func Description(event store.Event) string {
|
||||
about := event.Kind
|
||||
if event.Group != "" {
|
||||
about = joinNonEmpty(", ", about, "group "+event.Group)
|
||||
}
|
||||
lecturers := ""
|
||||
if event.Lecturers != "" {
|
||||
lecturers = "Lecturer: " + event.Lecturers
|
||||
}
|
||||
return joinNonEmpty("\n", about, lecturers, event.URL)
|
||||
}
|
||||
|
||||
func Write(w io.Writer, name, host string, events []store.Event) error {
|
||||
var b strings.Builder
|
||||
line(&b, "BEGIN:VCALENDAR")
|
||||
line(&b, "VERSION:2.0")
|
||||
line(&b, "PRODID:-//usoscal//EN")
|
||||
line(&b, "CALSCALE:GREGORIAN")
|
||||
line(&b, "METHOD:PUBLISH")
|
||||
line(&b, "X-WR-CALNAME:"+escape(name))
|
||||
line(&b, "X-WR-TIMEZONE:Europe/Warsaw")
|
||||
line(&b, "REFRESH-INTERVAL;VALUE=DURATION:PT6H")
|
||||
line(&b, "X-PUBLISHED-TTL:PT6H")
|
||||
for _, event := range events {
|
||||
line(&b, "BEGIN:VEVENT")
|
||||
line(&b, "UID:"+event.UID+"@"+host)
|
||||
line(&b, "DTSTAMP:"+utc(event.UpdatedAt))
|
||||
line(&b, "LAST-MODIFIED:"+utc(event.UpdatedAt))
|
||||
line(&b, "DTSTART:"+utc(event.Start))
|
||||
line(&b, "DTEND:"+utc(event.End))
|
||||
line(&b, "SUMMARY:"+escape(Summary(event)))
|
||||
if location := Location(event); location != "" {
|
||||
line(&b, "LOCATION:"+escape(location))
|
||||
}
|
||||
if description := Description(event); description != "" {
|
||||
line(&b, "DESCRIPTION:"+escape(description))
|
||||
}
|
||||
if event.URL != "" {
|
||||
line(&b, "URL:"+event.URL)
|
||||
}
|
||||
line(&b, "END:VEVENT")
|
||||
}
|
||||
line(&b, "END:VCALENDAR")
|
||||
_, err := io.WriteString(w, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func utc(t time.Time) string {
|
||||
return t.UTC().Format(stamp)
|
||||
}
|
||||
|
||||
func joinNonEmpty(separator string, parts ...string) string {
|
||||
kept := parts[:0:0]
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
kept = append(kept, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, separator)
|
||||
}
|
||||
|
||||
var escaper = strings.NewReplacer(`\`, `\\`, ";", `\;`, ",", `\,`, "\r\n", `\n`, "\n", `\n`, "\r", "")
|
||||
|
||||
func escape(text string) string {
|
||||
return escaper.Replace(text)
|
||||
}
|
||||
|
||||
func line(b *strings.Builder, content string) {
|
||||
for len(content) > lineLimit {
|
||||
cut := lineLimit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
b.WriteString(content[:cut])
|
||||
b.WriteString("\r\n ")
|
||||
content = content[cut:]
|
||||
}
|
||||
b.WriteString(content)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ical
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"usoscal/internal/store"
|
||||
)
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
warsaw, err := time.LoadLocation("Europe/Warsaw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := store.Event{
|
||||
UID: "sm-90917229",
|
||||
Start: time.Date(2026, 10, 9, 11, 45, 0, 0, warsaw),
|
||||
End: time.Date(2026, 10, 9, 13, 15, 0, 0, warsaw),
|
||||
Title: "Algebra liniowa z elementami matematyki dyskretnej",
|
||||
Kind: "Laboratorium",
|
||||
Group: "2",
|
||||
Room: "602",
|
||||
Building: "A1 - BM - Budowa Maszyn, budynek z zegarem",
|
||||
Address: "ul. Piotrowo 3, 61-138 Poznań, Polska",
|
||||
Lecturers: "dr inż. Zażółć Gęślą-Jaźń",
|
||||
URL: "https://usosweb.put.poznan.pl/kontroler.php?_action=x&gr_nr=2",
|
||||
UpdatedAt: time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC),
|
||||
}
|
||||
var out strings.Builder
|
||||
if err := Write(&out, "USOS", "usos.example", []store.Event{event}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := out.String()
|
||||
|
||||
for _, rawLine := range strings.Split(strings.TrimSuffix(text, "\r\n"), "\r\n") {
|
||||
if len(rawLine) > lineLimit+1 {
|
||||
t.Errorf("line longer than the fold limit: %q", rawLine)
|
||||
}
|
||||
if !utf8.ValidString(rawLine) {
|
||||
t.Errorf("fold split a character: %q", rawLine)
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ReplaceAll(text, "\r\n", ""), "\n") {
|
||||
t.Error("bare line feed in the output")
|
||||
}
|
||||
|
||||
unfolded := strings.ReplaceAll(text, "\r\n ", "")
|
||||
for _, want := range []string{
|
||||
"UID:sm-90917229@usos.example\r\n",
|
||||
"DTSTART:20261009T094500Z\r\n",
|
||||
"DTEND:20261009T111500Z\r\n",
|
||||
"SUMMARY:Algebra liniowa z elementami matematyki dyskretnej (Laboratorium)\r\n",
|
||||
`LOCATION:602 (A1 - BM - Budowa Maszyn\, budynek z zegarem)\, ul. Piotrowo 3\, 61-138 Poznań\, Polska` + "\r\n",
|
||||
`DESCRIPTION:Laboratorium\, group 2\nLecturer: dr inż. Zażółć Gęślą-Jaźń\nhttps://usosweb.put.poznan.pl/kontroler.php?_action=x&gr_nr=2` + "\r\n",
|
||||
"END:VCALENDAR\r\n",
|
||||
} {
|
||||
if !strings.Contains(unfolded, want) {
|
||||
t.Errorf("missing %q in\n%s", want, unfolded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparseEvent(t *testing.T) {
|
||||
event := store.Event{UID: "x", Title: "Spotkanie", Kind: "Spotkanie", Start: time.Unix(0, 0), End: time.Unix(60, 0)}
|
||||
if got := Summary(event); got != "Spotkanie" {
|
||||
t.Errorf("Summary = %q", got)
|
||||
}
|
||||
if got := Location(event); got != "" {
|
||||
t.Errorf("Location = %q", got)
|
||||
}
|
||||
if got := Description(event); got != "Spotkanie" {
|
||||
t.Errorf("Description = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user