Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Adjust/Hide Events/Courses #170

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 179 additions & 2 deletions internal/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package internal
import (
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"

ics "github.com/arran4/golang-ical"
"github.com/getsentry/sentry-go"
Expand Down Expand Up @@ -40,6 +43,20 @@ type Replacement struct {
value string
}

type Event struct {
RecurringId string `json:"recurringId"`
DtStart time.Time `json:"dtStart"`
DtEnd time.Time `json:"dtEnd"`
StartOffsetMinutes int `json:"startOffsetMinutes"`
EndOffsetMinutes int `json:"endOffsetMinutes"`
}

type Course struct {
Summary string `json:"summary"`
Hide bool `json:"hide"`
Recurrences map[string]Event `json:"recurrences"`
}

// for sorting replacements by length, then alphabetically
func (r1 *Replacement) isLessThan(r2 *Replacement) bool {
if len(r1.key) != len(r2.key) {
Expand Down Expand Up @@ -101,6 +118,7 @@ func (a *App) Run() error {
}

func (a *App) configRoutes() {
a.engine.GET("/api/courses", a.handleGetCourses)
a.engine.Any("/", a.handleIcal)
f := http.FS(static)
a.engine.StaticFS("/files/", f)
Expand Down Expand Up @@ -135,6 +153,38 @@ func getUrl(c *gin.Context) string {
return fmt.Sprintf("https://campus.tum.de/tumonlinej/ws/termin/ical?pStud=%s&pToken=%s", stud, token)
}

func parseOffsetsQuery(values []string) (map[int]int, error) {
offsets := make(map[int]int)

for _, value := range values {
parts := strings.Split(value, "+")
positive := true
if len(parts) != 2 {
parts = strings.Split(value, "-")
positive = false
if len(parts) != 2 {
return offsets, errors.New("OffsetsQuery was malformed")
}
}

id, err := strconv.Atoi(parts[0])
if err != nil {
return offsets, err
}
offset, err := strconv.Atoi(parts[1])
if err != nil {
return offsets, err
}

if positive == false {
offset = -1 * offset
}

offsets[id] = offset
}
return offsets, nil
}

func (a *App) handleIcal(c *gin.Context) {
url := getUrl(c)
if url == "" {
Expand All @@ -150,11 +200,24 @@ func (a *App) handleIcal(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
cleaned, err := a.getCleanedCalendar(all)
hide := c.QueryArray("hide")
startOffsets, err := parseOffsetsQuery(c.QueryArray("startOffset"))
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
endOffsets, err := parseOffsetsQuery(c.QueryArray("endOffset"))
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}

cleaned, err := a.getCleanedCalendar(all, hide, startOffsets, endOffsets)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}

response := []byte(cleaned.Serialize())
c.Header("Content-Type", "text/calendar")
c.Header("Content-Length", fmt.Sprintf("%d", len(response)))
Expand All @@ -164,7 +227,87 @@ func (a *App) handleIcal(c *gin.Context) {
}
}

func (a *App) getCleanedCalendar(all []byte) (*ics.Calendar, error) {
func (a *App) handleGetCourses(c *gin.Context) {
url := getUrl(c)
if url == "" {
return
}
resp, err := http.Get(url)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
all, err := io.ReadAll(resp.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}

cal, err := ics.ParseCalendar(strings.NewReader(string(all)))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}

// detect all courses, de-duplicate them by their summary (lecture name)
courses := make(map[string]Course)
for _, component := range cal.Components {
switch component.(type) {
case *ics.VEvent:
vEvent := component.(*ics.VEvent)
event := Event{
RecurringId: vEvent.GetProperty("X-CO-RECURRINGID").Value,
StartOffsetMinutes: 0,
EndOffsetMinutes: 0,
}

if event.DtStart, err = vEvent.GetStartAt(); err != nil {
continue
}
if event.DtEnd, err = vEvent.GetEndAt(); err != nil {
continue
}

eventSummary := vEvent.GetProperty(ics.ComponentPropertySummary).Value
course, exists := courses[eventSummary]

if exists == false {
course = Course{
Summary: eventSummary,
Hide: false,
Recurrences: map[string]Event{},
}
}

// only add recurring events
if event.RecurringId != "" {
course.Recurrences[event.RecurringId] = event
}
courses[eventSummary] = course

default:
continue
}
}

c.JSON(http.StatusOK, courses)
}

func stringEqualsOneOf(target string, listOfStrings []string) bool {
for _, element := range listOfStrings {
if target == element {
return true
}
}
return false
}

func (a *App) getCleanedCalendar(
all []byte,
hide []string,
startOffsets map[int]int,
endOffsets map[int]int,
) (*ics.Calendar, error) {
cal, err := ics.ParseCalendar(strings.NewReader(string(all)))
if err != nil {
return nil, err
Expand All @@ -178,11 +321,23 @@ func (a *App) getCleanedCalendar(all []byte) (*ics.Calendar, error) {
switch component.(type) {
case *ics.VEvent:
event := component.(*ics.VEvent)

// check if the summary contains any of the hidden keys, and if yes, skip it
eventSummary := event.GetProperty(ics.ComponentPropertySummary).Value
shouldHideEvent := stringEqualsOneOf(eventSummary, hide)
if shouldHideEvent {
continue
}

dedupKey := fmt.Sprintf("%s-%s", event.GetProperty(ics.ComponentPropertySummary).Value, event.GetProperty(ics.ComponentPropertyDtStart))
if _, ok := hasLecture[dedupKey]; ok {
continue
}
hasLecture[dedupKey] = true // mark event as seen

if recurringId, err := strconv.Atoi(event.GetProperty("X-CO-RECURRINGID").Value); err == nil {
a.adjustEventTimes(event, startOffsets[recurringId], endOffsets[recurringId])
}
a.cleanEvent(event)
newComponents = append(newComponents, event)
default: // keep everything that is not an event (metadata etc.)
Expand Down Expand Up @@ -218,6 +373,28 @@ var unneeded = []string{

var reRoom = regexp.MustCompile("^(.*?),.*(\\d{4})\\.(?:\\d\\d|EG|UG|DG|Z\\d|U\\d)\\.\\d+")

func (a *App) adjustEventTimes(event *ics.VEvent, startOffset int, endOffset int) {
if startOffset != 0 {
if start, err := event.GetStartAt(); err == nil {
start = start.Add(time.Minute * time.Duration(startOffset))
event.SetStartAt(start)

if d := event.GetProperty(ics.ComponentPropertyDescription); d != nil {
event.SetDescription(d.Value + fmt.Sprintf("; start offset: %d", startOffset))
}
}
}
if endOffset != 0 {
if end, err := event.GetEndAt(); err == nil {
end = end.Add(time.Minute * time.Duration(endOffset))
event.SetEndAt(end)
if d := event.GetProperty(ics.ComponentPropertyDescription); d != nil {
event.SetDescription(d.Value + fmt.Sprintf("; end offset: %dm", endOffset))
}
}
}
}

func (a *App) cleanEvent(event *ics.VEvent) {
summary := ""
if s := event.GetProperty(ics.ComponentPropertySummary); s != nil {
Expand Down
107 changes: 103 additions & 4 deletions internal/app_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package internal

import (
ics "github.com/arran4/golang-ical"
"io"
"os"
"strings"
"testing"

ics "github.com/arran4/golang-ical"
)

func getTestData(t *testing.T, name string) (string, *App) {
Expand Down Expand Up @@ -58,7 +60,7 @@ func TestReplacement(t *testing.T) {

func TestDeduplication(t *testing.T) {
testData, app := getTestData(t, "duplication.ics")
calendar, err := app.getCleanedCalendar([]byte(testData))
calendar, err := app.getCleanedCalendar([]byte(testData), []string{}, map[int]int{}, map[int]int{})
if err != nil {
t.Error(err)
return
Expand All @@ -71,7 +73,7 @@ func TestDeduplication(t *testing.T) {

func TestNameShortening(t *testing.T) {
testData, app := getTestData(t, "nameshortening.ics")
calendar, err := app.getCleanedCalendar([]byte(testData))
calendar, err := app.getCleanedCalendar([]byte(testData), []string{}, map[int]int{}, map[int]int{})
if err != nil {
t.Error(err)
return
Expand All @@ -85,7 +87,7 @@ func TestNameShortening(t *testing.T) {

func TestLocationReplacement(t *testing.T) {
testData, app := getTestData(t, "location.ics")
calendar, err := app.getCleanedCalendar([]byte(testData))
calendar, err := app.getCleanedCalendar([]byte(testData), []string{}, map[int]int{}, map[int]int{})
if err != nil {
t.Error(err)
return
Expand All @@ -101,3 +103,100 @@ func TestLocationReplacement(t *testing.T) {
return
}
}

func TestCourseFiltering(t *testing.T) {
testData, app := getTestData(t, "coursefiltering.ics")

// make sure the unfiltered calendar has 2 entries
fullCalendar, err := app.getCleanedCalendar([]byte(testData), []string{}, map[int]int{}, map[int]int{})
if err != nil {
t.Error(err)
return
}
if len(fullCalendar.Components) != 2 {
t.Errorf("Calendar should have 2 entries before course filtering but has %d", len(fullCalendar.Components))
return
}

// now filter out one course
filter := "Einführung in die Rechnerarchitektur (IN0004) VO\\, Standardgruppe"
filteredCalendar, err := app.getCleanedCalendar([]byte(testData), []string{filter}, map[int]int{}, map[int]int{})
if err != nil {
t.Error(err)
return
}
if len(filteredCalendar.Components) != 1 {
t.Errorf("Calendar should have only 1 entry after course filtering but has %d", len(filteredCalendar.Components))
return
}

// make sure the summary does not contain the filtered course's name
summary := filteredCalendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertySummary).Value
if strings.Contains(summary, filter) {
t.Errorf("Summary should not contain %s but is %s", filter, summary)
return
}
}

func TestCourseTimeAdjustment(t *testing.T) {
testData, app := getTestData(t, "timeadjustment.ics")

startOffsets := map[int]int {
583745: 15,
}

endOffsets := map[int]int {
583745: 0,
583744: -14,
}

adjCal, err := app.getCleanedCalendar([]byte(testData), []string{}, startOffsets, endOffsets)
if err != nil {
t.Error(err)
return
}
if len(adjCal.Components) != 4 {
t.Errorf("Calendar should have 4 entries before time adjustment but was %d", len(adjCal.Components))
return
}

// first entry (recurring id 583745): only start offset expected (+15)
if start := adjCal.Components[0].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtstart)).Value; start != "20240109T171500Z" {
t.Errorf("start (+15) should have been 20240109T171500Z but was %s", start)
return
}
if end := adjCal.Components[0].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtend)).Value; end != "20240109T190000Z" {
t.Errorf("end (+0) should have been 20240109T190000Z but was %s", end)
return
}

// second entry (recurring id 583744): only end offset expected (-14)
if start := adjCal.Components[1].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtstart)).Value; start != "20231113T170000Z" {
t.Errorf("start (+/- n.a.) should have been 20231113T170000Z but was %s", start)
return
}
if end := adjCal.Components[1].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtend)).Value; end != "20231113T184600Z" {
t.Errorf("end (-14) should have been 20231113T184600Z but was %s", end)
return
}

// third entry (no recurring id): expect no adjustments
if start := adjCal.Components[2].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtstart)).Value; start != "20231023T160000Z" {
t.Errorf("start (+/- n.a.) should have been 20231023T160000Z but was %s", start)
return
}
if end := adjCal.Components[2].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtend)).Value; end != "20231023T180000Z" {
t.Errorf("end (+/- n.a.) should have been 20231023T180000Z but was %s", end)
return
}

// fourth entry (recurring id 583745): expect only start offset expected (+15)
if start := adjCal.Components[3].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtstart)).Value; start != "20240206T171500Z" {
t.Errorf("start (+/- n.a.) should have been 20240206T171500Z but was %s", start)
return
}
if end := adjCal.Components[3].(*ics.VEvent).GetProperty(ics.ComponentProperty(ics.PropertyDtend)).Value; end != "20240206T190000Z" {
t.Errorf("end (+/- n.a.) should have been 20240206T190000Z but was %s", end)
return
}
}
Loading
Loading