diff --git a/Dockerfile b/Dockerfile index 68f55bf..2c537c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21 +FROM golang:1.22 ADD . /app WORKDIR /app RUN go mod vendor diff --git a/cmd/main.go b/cmd/main.go index 5bc691f..ff662db 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,9 +5,10 @@ import ( "fmt" "github.com/akamensky/argparse" ics "github.com/arran4/golang-ical" - "github.com/ski7777/asw-stundenplan/pkg/ical" + "github.com/ski7777/asw-stundenplan/pkg/extended_event" + "github.com/ski7777/asw-stundenplan/pkg/roomheatmap" + "github.com/ski7777/asw-stundenplan/pkg/rooms" "github.com/ski7777/asw-stundenplan/pkg/timetablelist" - "github.com/ski7777/sked-campus-html-parser/pkg/timetable" "github.com/ski7777/sked-campus-html-parser/pkg/timetablepage" "github.com/thoas/go-funk" "log" @@ -32,6 +33,9 @@ func main() { if _, err := os.Stat(*outputdir); os.IsNotExist(err) { log.Fatalln("output directory does not exist") } + if err := os.MkdirAll(path.Join(*outputdir, "rooms"), 0755); err != nil { + log.Fatalln(err) + } tz, err := time.LoadLocation(*timezone) if err != nil { log.Fatalln(err) @@ -87,7 +91,7 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti threadErrors []error wg sync.WaitGroup eventsMutex = &sync.Mutex{} - events = make(map[string]map[string]timetable.Event) //class --> {id --> event} + events = make(map[string]map[string]*extended_event.ExtendedEvent) //class --> {id --> event} ) log.Println("scraping all timetablepages") for cn, cttm := range ttm { @@ -105,10 +109,10 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti } eventsMutex.Lock() defer eventsMutex.Unlock() - var classEvents map[string]timetable.Event + var classEvents map[string]*extended_event.ExtendedEvent var ok bool if classEvents, ok = events[cn]; !ok { - classEvents = make(map[string]timetable.Event) + classEvents = make(map[string]*extended_event.ExtendedEvent) } defer func() { events[cn] = classEvents }() for id, e := range ttp.AllEvents { @@ -118,7 +122,7 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti threadErrors = append(threadErrors, errors.New(fmt.Sprintf("duplicate event id: class: %s, block: %d, event: %s", cn, bn, id))) return } - classEvents[id] = e + classEvents[id] = extended_event.NewExtendedEvent(e, id, cn, tz) } }(cn, bn, tturl) @@ -136,7 +140,7 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti "scraped %d events for %d classes", funk.Reduce( funk.Values(events), - func(acc int, cem map[string]timetable.Event) int { + func(acc int, cem map[string]*extended_event.ExtendedEvent) int { return acc + len(cem) }, 0, @@ -162,7 +166,7 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti } for cn, ce := range events { wg.Add(1) - go func(cn string, ce map[string]timetable.Event) { + go func(cn string, ce map[string]*extended_event.ExtendedEvent) { defer wg.Done() cal := ics.NewCalendarFor(cn) cal.SetXPublishedTTL("PT10M") @@ -172,9 +176,9 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti cal.SetTimezoneId(tz.String()) cal.SetLastModified(now) cal.SetProductId(fmt.Sprintf("Stundenplan für die Klasse %s der ASW gGmbH.", cn)) - cal.SetDescription("Weitere INformationen zum Stundenplan finden Sie unter https://github.com/ski7777/asw-stundenplan") - for id, e := range ce { - cal.AddVEvent(ical.ConvertEvent(e, id, tz)) + cal.SetDescription("Weitere Informationen zum Stundenplan finden Sie unter https://github.com/ski7777/asw-stundenplan") + for _, e := range ce { + cal.AddVEvent(e.ToVEvent()) } if motd != nil { cal.AddVEvent(motd) @@ -202,5 +206,60 @@ func run(tz *time.Location, outputdir string, motdSummary *string, motdDescripti } log.Fatalln("exiting due to errors above") } + log.Println("generating room heatmaps") + for rhmd := 0; rhmd < 14; rhmd++ { + go func(rhmd int) { + rhmstart := time.Date( + now.Year(), + now.Month(), + now.Day(), + 0, + 0, + 0, + 0, + tz, + ).Add(time.Hour * 24 * time.Duration(rhmd)) + rhmend := rhmstart.Add(time.Hour * 24) // 1 day + rhm := roomheatmap.NewRoomHeatmap( + rhmstart, + rhmend, + time.Minute*15, + rooms.Rooms, + tz, + funk.Reduce(funk.Values(events), func(acc []*extended_event.ExtendedEvent, ae map[string]*extended_event.ExtendedEvent) []*extended_event.ExtendedEvent { + return append(acc, funk.Values(ae).([]*extended_event.ExtendedEvent)...) + }, []*extended_event.ExtendedEvent{}).([]*extended_event.ExtendedEvent), + ) + rhm.MinTime = time.Date(0, 0, 0, 8, 0, 0, 0, tz) + rhm.MaxTime = time.Date(0, 0, 0, 20, 0, 0, 0, tz) + + af, err := os.Create(path.Join(outputdir, "rooms", fmt.Sprintf("%02d.html", rhmd))) + if err != nil { + threadErrors = append(threadErrors, err) + return + } + defer func(af *os.File) { + _ = af.Close() + }(af) + var rhmhtml string + rhmhtml, err = rhm.GenHTML() + if err != nil { + threadErrors = append(threadErrors, err) + return + } + _, err = af.WriteString(rhmhtml) + if err != nil { + threadErrors = append(threadErrors, err) + return + } + }(rhmd) + } + wg.Wait() + if len(threadErrors) > 0 { + for _, e := range threadErrors { + log.Println(e) + } + log.Fatalln("exiting due to errors above") + } log.Println("done") } diff --git a/go.mod b/go.mod index f192ca6..a19650a 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/ski7777/asw-stundenplan -go 1.21 - -toolchain go1.21.3 +go 1.22.0 require ( github.com/PuerkitoBio/goquery v1.8.1 diff --git a/pkg/extended_event/extended_event.go b/pkg/extended_event/extended_event.go new file mode 100644 index 0000000..5a8fa65 --- /dev/null +++ b/pkg/extended_event/extended_event.go @@ -0,0 +1,109 @@ +package extended_event + +import ( + "fmt" + ics "github.com/arran4/golang-ical" + "github.com/ski7777/asw-stundenplan/pkg/rooms" + "github.com/ski7777/sked-campus-html-parser/pkg/timetable" + "github.com/thoas/go-funk" + "strings" + "time" +) + +type ExtendedEvent struct { + Event timetable.Event + ID string + Begin, End time.Time + Class string + + timezone *time.Location + Summary, Location, Description *string + timeTransparency ics.TimeTransparency + status ics.ObjectStatus +} + +func (ee *ExtendedEvent) ToVEvent() *ics.VEvent { + ie := ics.NewEvent(ee.ID) + ie.SetDtStampTime(time.Now()) + ie.SetStartAt(ee.Begin) + ie.SetEndAt(ee.End) + if ee.Summary != nil { + ie.SetSummary(*ee.Summary) + } + if ee.Location != nil { + ie.SetLocation(*ee.Location) + } + if ee.Description != nil { + ie.SetDescription(*ee.Description) + } + ie.SetTimeTransparency(ee.timeTransparency) + ie.SetStatus(ee.status) + return ie +} + +func (ee *ExtendedEvent) GetRooms() (rl []string) { + rl = []string{} + if ee.Location == nil { + return + } + for _, match := range rooms.RoomRegex.FindAllString(*ee.Location, -1) { + rl = append(rl, match) + } + return +} + +func (ee *ExtendedEvent) String() string { + var data = []string{ee.Class} + if ee.Summary != nil { + data = append(data, *ee.Summary+":") + } + data = append(data, ee.Begin.Format("15:04")+"-"+ee.End.Format("15:04")) + if ee.Location != nil { + data = append(data, "Ort:"+*ee.Location) + } + if ee.Description != nil { + data = append(data, *ee.Description) + } + return strings.Join(data, "\n") +} + +func NewExtendedEvent(se timetable.Event, id string, cn string, timezone *time.Location) *ExtendedEvent { + ee := &ExtendedEvent{ + Event: se, + ID: id, + Class: cn, + timezone: timezone, + Begin: se.Begin.ToTime(se.Date, timezone), + End: se.End.ToTime(se.Date, timezone), + timeTransparency: ics.TransparencyOpaque, + status: ics.ObjectStatusConfirmed, + } + if len(se.Text) > 0 { + ee.Summary = &se.Text[0] + if se.Text[0] == "Reserviert" { + ee.timeTransparency = ics.TransparencyTransparent + ee.status = ics.ObjectStatusTentative + } + } + if len(se.Text) > 1 { + *ee.Summary = fmt.Sprintf("%s (%s)", se.Text[1], se.Text[0]) + var locline string + if roomlines := funk.FilterString(se.Text, func(s string) bool { + return strings.HasPrefix(s, "NK: ") || strings.HasPrefix(s, "EXT: ") + }); len(roomlines) > 0 { + locline = roomlines[0] + loc := locline + loc = strings.ReplaceAll(loc, "NK: ", "") + loc = strings.ReplaceAll(loc, "DV ", "") + ee.Location = &loc + } + if desclines := funk.FilterString(se.Text[2:], func(s string) bool { + return s != locline + }); len(desclines) > 0 { + var desc string + desc = strings.Join(desclines, "\n") + ee.Description = &desc + } + } + return ee +} diff --git a/pkg/roomheatmap/html.go b/pkg/roomheatmap/html.go new file mode 100644 index 0000000..ba88368 --- /dev/null +++ b/pkg/roomheatmap/html.go @@ -0,0 +1,9 @@ +package roomheatmap + +import _ "embed" +import "html/template" + +//go:embed roomheatmap.gohtml +var rhm_template_raw string + +var rhm_template = template.Must(template.New("roomheatmap").Parse(rhm_template_raw)) diff --git a/pkg/roomheatmap/roomheatmap.go b/pkg/roomheatmap/roomheatmap.go new file mode 100644 index 0000000..6f0cae3 --- /dev/null +++ b/pkg/roomheatmap/roomheatmap.go @@ -0,0 +1,130 @@ +package roomheatmap + +import ( + "bytes" + "fmt" + "github.com/ski7777/asw-stundenplan/pkg/extended_event" + "github.com/thoas/go-funk" + "sort" + "time" +) + +type RoomHeatmap struct { + Interval time.Duration + Start time.Time + End time.Time + Rooms []string + Slots []map[string][]*extended_event.ExtendedEvent // [Slot Index](Room Name -> []Event) + MinTime, MaxTime time.Time +} + +func (r *RoomHeatmap) AddEvents(el []*extended_event.ExtendedEvent) { + for _, e := range el { + r.AddEvent(e) + } +} + +func (r *RoomHeatmap) AddEvent(e *extended_event.ExtendedEvent) { + if e.Begin.After(r.End) || e.End.Before(r.Start) { + return + } + startslot := int(e.Begin.Sub(r.Start) / r.Interval) + endslot := int((e.End.Sub(r.Start) - time.Second) / r.Interval) + if startslot < 0 { + startslot = 0 + } + if endslot >= len(r.Slots) { + endslot = len(r.Slots) - 1 + } + rooms := funk.IntersectString(e.GetRooms(), r.Rooms) + for sid := startslot; sid <= endslot; sid++ { + for _, room := range rooms { + if _, ok := r.Slots[sid][room]; !ok { + continue + } + r.Slots[sid][room] = append(r.Slots[sid][room], e) + } + } +} + +func (r *RoomHeatmap) GenHTML() (string, error) { + type slot struct { + X string `json:"x"` //Time + Y string `json:"y"` //Room + Heat int `json:"heat"` + Text string `json:"text"` + } + var data []slot + for sid, s := range r.Slots { + t := r.Start.Add(time.Duration(int(r.Interval) * sid)) + daytime := time.Date(0, 0, 0, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()) + if daytime.Before(r.MinTime) || daytime.Add(time.Second).After(r.MaxTime) { + continue + } + for room, events := range s { + data = append(data, slot{ + X: t.Format("02.01. 15:04"), + Y: room, + Heat: len(events), + Text: func() (text string) { + for i, e := range events { + if len(events) > 1 { + text += fmt.Sprintf("Veranstaltung %d:\n", i+1) + } + text += e.String() + if i < len(events)-1 { + text += "\n\n" + } + } + return + }(), + }) + } + } + + sort.Slice(data, func(i, j int) bool { + if data[i].X != data[j].X { + return data[i].X < data[j].X + } else { + return data[i].Y < data[j].Y + } + }) + + var buf bytes.Buffer + err := rhm_template.Execute(&buf, struct { + Data []slot + Rooms []string + }{ + Data: data, + Rooms: r.Rooms, + }) + if err != nil { + return "", err + } + return buf.String(), nil +} + +func NewRoomHeatmap(start time.Time, end time.Time, interval time.Duration, rooms []string, tz *time.Location, events []*extended_event.ExtendedEvent) *RoomHeatmap { + roomHeatmap := &RoomHeatmap{ + Interval: interval, + Start: start, + End: end, + Slots: make([]map[string][]*extended_event.ExtendedEvent, end.Sub(start)/interval), + Rooms: rooms, + MinTime: time.Date(0, 0, 0, 0, 0, 0, 0, tz), + MaxTime: time.Date(0, 0, 0, 24, 0, 0, 0, tz), + } + + sort.Strings(roomHeatmap.Rooms) + + for i := range roomHeatmap.Slots { + roomHeatmap.Slots[i] = make(map[string][]*extended_event.ExtendedEvent) + for _, room := range rooms { + roomHeatmap.Slots[i][room] = []*extended_event.ExtendedEvent{} + } + } + + roomHeatmap.AddEvents(events) + + return roomHeatmap +} diff --git a/pkg/roomheatmap/roomheatmap.gohtml b/pkg/roomheatmap/roomheatmap.gohtml new file mode 100644 index 0000000..b41cad4 --- /dev/null +++ b/pkg/roomheatmap/roomheatmap.gohtml @@ -0,0 +1,37 @@ + +
+ + + + + + + +