Skip to content

Commit 0dc03e5

Browse files
kordianbruckFlorianPfistererPSandrojoschahenningsennycex
authored
Feature: Adjust/Hide Events/Courses (#197)
* feat: allow filtering courses from calendar * feat: fix tests, add filtering test * fix: adjust course tag regex * fix: address style comments, fix switch statement * feat: filter by course name, not tag * replace redundant code * fix variable rename during rebase * app_test: fix course filtering * add recurrences to course adjustment view * change 'filter' to 'hide', add offet input * output course adjustments as query params * remove todo for prefilling course adjustments In order to be able to prefill the course adjustment page with previously selected values, we would need to have the user provide his https://campus.tum... link instead of https://cal.tum.ap... It's probably better to just store the settings in the browser and load them when revisiting the site. * parse offsets and apply to vevents * fix parseOffsetQuery * adjustEventTimes: actually do the adjustment (facepalm) * adjustTimes: use endOffset (not startOffset) * fix test: change of getCleanedCalendar parameters * add test for time adjustment * display recurringid on course adjustment section * Fix startOffset generation and improve parsing robustness (#198) - Fixed a bug in `internal/static/main.js` where negative start offsets were generated with an extra space (e.g., `123 -10` instead of `123-10`), causing parsing errors on the backend. - Updated `internal/app.go` `parseOffsetsQuery` to trim spaces from input parts, making the backend robust against malformed or space-padded offset strings. - Refactored boolean checks in `internal/app.go` to use idiomatic Go (e.g., `!exists` instead of `exists == false`). - Added comprehensive unit tests for `parseOffsetsQuery` in `internal/app_test.go` covering valid, negative, malformed, and space-padded inputs. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * Remove time adjustment query parameters and functionality (#199) - Removed `startOffset` and `endOffset` query parameter handling in `internal/app.go`. - Removed `adjustEventTimes` logic for shifting event times. - Updated `getCleanedCalendar` signature to remove offset maps. - Removed frontend UI and logic for setting time offsets in `internal/static/main.js`. - Cleaned up tests in `internal/app_test.go` by removing `TestCourseTimeAdjustment` and updating calls. - Removed unused imports in `internal/app.go`. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Kordian Bruck <[email protected]> * Fix imports * Fix variable overload * Use non versioned alpine image for go, instead of locking the alpine version. * Remove test cases for time adjustments * Remove time adjustment code * Fixups to make this work properly. * Cleanup the code a bit more. * Properly cleanup the summary for hiding courses. * Make sure to respect the boolean when populating the checkboxes in the frontend. * Fix test cases --------- Co-authored-by: Florian Pfisterer <[email protected]> Co-authored-by: Sandro Pischinger <[email protected]> Co-authored-by: Joscha Henningsen <[email protected]> Co-authored-by: nycex <[email protected]> Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 66ac945 commit 0dc03e5

File tree

8 files changed

+353
-44
lines changed

8 files changed

+353
-44
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM golang:1.24-alpine3.20 as builder
1+
FROM golang:1.24-alpine as builder
22

33
# Ca-certificates are required to call HTTPS endpoints.
44
RUN apk update && apk add --no-cache ca-certificates tzdata alpine-sdk bash && update-ca-certificates

internal/app.go

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package internal
33
import (
44
"embed"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
9+
"log"
810
"net/http"
911
"net/url"
1012
"regexp"
@@ -41,6 +43,11 @@ type Replacement struct {
4143
value string
4244
}
4345

46+
type Course struct {
47+
Summary string `json:"summary"`
48+
Hide bool `json:"hide"`
49+
}
50+
4451
// for sorting replacements by length, then alphabetically
4552
func (r1 *Replacement) isLessThan(r2 *Replacement) bool {
4653
if len(r1.key) != len(r2.key) {
@@ -141,6 +148,7 @@ func (a *App) Run() error {
141148
}
142149

143150
func (a *App) configRoutes() {
151+
a.engine.GET("/api/courses", a.handleGetCourses)
144152
a.engine.GET("/health", func(c *gin.Context) {
145153
c.JSON(http.StatusOK, gin.H{
146154
"status": "ok",
@@ -180,54 +188,119 @@ func getUrl(c *gin.Context) string {
180188
return fmt.Sprintf("https://campus.tum.de/tumonlinej/ws/termin/ical?pStud=%s&pToken=%s", stud, token)
181189
}
182190

183-
func (a *App) handleIcal(c *gin.Context) {
184-
url := getUrl(c)
185-
if url == "" {
186-
return
191+
func getCalendar(ctx *gin.Context) ([]byte, map[string]bool, error) {
192+
fetchURL := getUrl(ctx)
193+
if fetchURL == "" {
194+
return nil, nil, errors.New("no fetchable URL passed")
187195
}
188-
resp, err := http.Get(url)
196+
resp, err := http.Get(fetchURL)
189197
if err != nil {
190-
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
191-
return
198+
return nil, nil, fmt.Errorf("can't fetch calendar: %w", err)
192199
}
193200
all, err := io.ReadAll(resp.Body)
194201
if err != nil {
195-
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
202+
return nil, nil, fmt.Errorf("can't read calendar: %w", err)
203+
}
204+
205+
// Create map of all hidden courses
206+
hide := ctx.QueryArray("hide")
207+
hiddenCourses := make(map[string]bool)
208+
for _, course := range hide {
209+
hiddenCourses[course] = true
210+
}
211+
212+
return all, hiddenCourses, nil
213+
}
214+
215+
// handleIcal returns a filtered calendar with all courses that are currently offered on campus.
216+
func (a *App) handleIcal(ctx *gin.Context) {
217+
allEvents, hiddenCourses, err := getCalendar(ctx)
218+
if err != nil {
219+
ctx.AbortWithStatusJSON(http.StatusInternalServerError, err)
196220
return
197221
}
198-
cleaned, err := a.getCleanedCalendar(all)
222+
223+
cleaned, err := a.getCleanedCalendar(allEvents, hiddenCourses)
199224
if err != nil {
200-
c.AbortWithStatus(http.StatusInternalServerError)
225+
ctx.AbortWithStatus(http.StatusInternalServerError)
201226
return
202227
}
228+
203229
response := []byte(cleaned.Serialize())
204-
c.Header("Content-Type", "text/calendar")
205-
c.Header("Content-Length", fmt.Sprintf("%d", len(response)))
230+
ctx.Header("Content-Type", "text/calendar")
231+
ctx.Header("Content-Length", fmt.Sprintf("%d", len(response)))
206232

207-
if _, err := c.Writer.Write(response); err != nil {
233+
if _, err := ctx.Writer.Write(response); err != nil {
208234
sentry.CaptureException(err)
209235
}
210236
}
211237

212-
func (a *App) getCleanedCalendar(all []byte) (*ics.Calendar, error) {
238+
// handleGetCourses returns a list of all courses that are currently offered on campus.
239+
// This is used to populate the dropdown in the landing page for hiding courses.
240+
func (a *App) handleGetCourses(ctx *gin.Context) {
241+
allEvents, hidden, err := getCalendar(ctx)
242+
if err != nil {
243+
ctx.AbortWithStatusJSON(http.StatusInternalServerError, err)
244+
return
245+
}
246+
247+
cal, err := a.getCleanedCalendar(allEvents, map[string]bool{})
248+
if err != nil {
249+
ctx.AbortWithStatus(http.StatusInternalServerError)
250+
return
251+
}
252+
253+
// detect all courses, de-duplicate them by their summary (lecture name)
254+
courses := make(map[string]Course)
255+
for _, component := range cal.Components {
256+
switch component.(type) {
257+
case *ics.VEvent:
258+
eventSummary := cleanEventSummary(component.(*ics.VEvent).GetProperty(ics.ComponentPropertySummary).Value)
259+
if _, exists := courses[eventSummary]; !exists {
260+
courses[eventSummary] = Course{
261+
Summary: eventSummary,
262+
// Check for existing hidden course, that might want to be updated
263+
Hide: hidden[eventSummary],
264+
}
265+
}
266+
log.Printf("summaries: %s", eventSummary)
267+
default:
268+
continue
269+
}
270+
}
271+
272+
ctx.JSON(http.StatusOK, courses)
273+
}
274+
275+
func (a *App) getCleanedCalendar(all []byte, hiddenCourses map[string]bool) (*ics.Calendar, error) {
213276
cal, err := ics.ParseCalendar(strings.NewReader(string(all)))
214277
if err != nil {
215278
return nil, err
216279
}
217280

218-
// Create map that tracks if we have allready seen a lecture name & datetime (e.g. "lecturexyz-1.2.2024 10:00" -> true)
281+
// Create map that tracks if we have already seen a lecture name & datetime (e.g. "lecturexyz-1.2.2024 10:00" -> true)
219282
hasLecture := make(map[string]bool)
220283
var newComponents []ics.Component // saves the components we keep because they are not duplicated
221284

222285
for _, component := range cal.Components {
223286
switch component.(type) {
224287
case *ics.VEvent:
225288
event := component.(*ics.VEvent)
289+
290+
// check if the summary contains any of the hidden keys, and if yes, skip it
291+
eventSummary := event.GetProperty(ics.ComponentPropertySummary).Value
292+
if hiddenCourses[eventSummary] {
293+
continue
294+
}
295+
296+
// deduplicate lectures by their summary and datetime
226297
dedupKey := fmt.Sprintf("%s-%s", event.GetProperty(ics.ComponentPropertySummary).Value, event.GetProperty(ics.ComponentPropertyDtStart))
227298
if _, ok := hasLecture[dedupKey]; ok {
228299
continue
229300
}
230301
hasLecture[dedupKey] = true // mark event as seen
302+
303+
// clean up the event
231304
a.cleanEvent(event)
232305
newComponents = append(newComponents, event)
233306
default: // keep everything that is not an event (metadata etc.)
@@ -248,7 +321,7 @@ var reLoc = regexp.MustCompile(" ?(München|Garching|Weihenstephan).+")
248321
// Matches repeated whitespaces
249322
var reSpace = regexp.MustCompile(`\s\s+`)
250323

251-
// Matches weird starting numbers like "0000002467 " in "0000002467 Semantik"
324+
// Matches unique starting numbers like "0000002467 " in "0000002467 Semantik"
252325
var reWeirdStartingNumbers = regexp.MustCompile(`^0\d+ `)
253326

254327
var unneeded = []string{
@@ -272,7 +345,7 @@ var reNavigaTUM = regexp.MustCompile("\\(\\d{4}\\.[a-zA-Z0-9]{2}\\.\\d{3}[A-Z]?\
272345
func (a *App) cleanEvent(event *ics.VEvent) {
273346
summary := ""
274347
if s := event.GetProperty(ics.ComponentPropertySummary); s != nil {
275-
summary = strings.ReplaceAll(s.Value, "\\", "")
348+
summary = cleanEventSummary(s.Value)
276349
}
277350

278351
description := ""
@@ -285,9 +358,9 @@ func (a *App) cleanEvent(event *ics.VEvent) {
285358
location = strings.ReplaceAll(event.GetProperty(ics.ComponentPropertyLocation).Value, "\\", "")
286359
}
287360

288-
//Remove the TAG and anything after e.g.: (IN0001) or [MA0001]
361+
// Remove the TAG and anything after e.g.: (IN0001) or [MA0001]
289362
summary = reTag.ReplaceAllString(summary, "")
290-
//remove location and teacher from language course title
363+
// remove location and teacher from the language course title
291364
summary = reLoc.ReplaceAllString(summary, "")
292365
summary = reSpace.ReplaceAllString(summary, "")
293366
for _, replace := range unneeded {
@@ -299,7 +372,7 @@ func (a *App) cleanEvent(event *ics.VEvent) {
299372

300373
event.SetSummary(summary)
301374

302-
//Remember the old title in the description
375+
// Remember the old title in the description
303376
description = summary + "\n" + description
304377

305378
results := reRoom.FindStringSubmatch(location)
@@ -329,3 +402,10 @@ func (a *App) cleanEvent(event *ics.VEvent) {
329402
event.SetStatus(ics.ObjectStatusTentative)
330403
}
331404
}
405+
406+
func cleanEventSummary(eventSummary string) string {
407+
eventSummary = strings.ReplaceAll(eventSummary, "\\", "")
408+
eventSummary = strings.TrimSpace(eventSummary)
409+
eventSummary = strings.TrimSuffix(eventSummary, " ,")
410+
return eventSummary
411+
}

internal/app_test.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package internal
22

33
import (
4-
ics "github.com/arran4/golang-ical"
54
"io"
65
"os"
6+
"strings"
77
"testing"
8+
9+
ics "github.com/arran4/golang-ical"
810
)
911

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

5961
func TestDeduplication(t *testing.T) {
6062
testData, app := getTestData(t, "duplication.ics")
61-
calendar, err := app.getCleanedCalendar([]byte(testData))
63+
calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{})
6264
if err != nil {
6365
t.Error(err)
6466
return
@@ -71,7 +73,7 @@ func TestDeduplication(t *testing.T) {
7173

7274
func TestNameShortening(t *testing.T) {
7375
testData, app := getTestData(t, "nameshortening.ics")
74-
calendar, err := app.getCleanedCalendar([]byte(testData))
76+
calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{})
7577
if err != nil {
7678
t.Error(err)
7779
return
@@ -85,7 +87,7 @@ func TestNameShortening(t *testing.T) {
8587

8688
func TestLocationReplacement(t *testing.T) {
8789
testData, app := getTestData(t, "location.ics")
88-
calendar, err := app.getCleanedCalendar([]byte(testData))
90+
calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{})
8991
if err != nil {
9092
t.Error(err)
9193
return
@@ -103,3 +105,37 @@ func TestLocationReplacement(t *testing.T) {
103105
return
104106
}
105107
}
108+
109+
func TestCourseFiltering(t *testing.T) {
110+
testData, app := getTestData(t, "coursefiltering.ics")
111+
112+
// make sure the unfiltered calendar has 2 entries
113+
fullCalendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{})
114+
if err != nil {
115+
t.Error(err)
116+
return
117+
}
118+
if len(fullCalendar.Components) != 2 {
119+
t.Errorf("Calendar should have 2 entries before course filtering but has %d", len(fullCalendar.Components))
120+
return
121+
}
122+
123+
// now filter out one course
124+
filter := "Einführung in die Rechnerarchitektur (IN0004) VO\\, Standardgruppe"
125+
filteredCalendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{filter: true})
126+
if err != nil {
127+
t.Error(err)
128+
return
129+
}
130+
if len(filteredCalendar.Components) != 1 {
131+
t.Errorf("Calendar should have only 1 entry after course filtering but has %d", len(filteredCalendar.Components))
132+
return
133+
}
134+
135+
// make sure the summary does not contain the filtered course's name
136+
summary := filteredCalendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertySummary).Value
137+
if strings.Contains(summary, filter) {
138+
t.Errorf("Summary should not contain %s but is %s", filter, summary)
139+
return
140+
}
141+
}

internal/static/index.html

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ <h2>About</h2>
4747
</li>
4848
<li>Replaces 'Tutorübung' with 'TÜ'</li>
4949
<li>Remove event duplicates due to multiple rooms</li>
50+
<li>Allows you to hide courses in your calendar without de-registering in TUMOnline</li>
5051
</ul>
5152

5253
<h2>HowTo</h2>
@@ -62,6 +63,7 @@ <h2>HowTo</h2>
6263
type="url"
6364
id="tumCalLink"
6465
placeholder="https://campus.tum.de/tumonlinej/ws/termin/ical?pStud=abc&pToken=xyz"
66+
oninput="reloadCourses()"
6567
/>
6668
<button id="generateLinkBtn" onclick="generateLink()">
6769
Generate & Copy
@@ -74,15 +76,14 @@ <h2>HowTo</h2>
7476
Go to Google Calendar (or similar) and import the resulting url
7577
</li>
7678
</ol>
77-
<i>
78-
If step 2 does not work, try to copy 'n' Paste the query string
79-
(everything after the ? sign, e.g. "?pStud=ABCDEF&pToken=XYZ") and
80-
append it to this url:
81-
<a href="#dontclickme">https://cal.tum.app/</a> so it looks like this:
82-
<a href="#dontclickme"
83-
>https://cal.tum.app/?pStud=ABCDEF&amp;pToken=XYZ</a
84-
>
85-
</i>
79+
80+
<div id="courseAdjustDiv" hidden>
81+
<h2>Adjust Courses</h2>
82+
<p>
83+
You can hide courses from your calendar by un-ticking the checkmarks next to them. This allows you to clean up your calendar without deregistering from the course in TUMOnline. Make sure to click the "Generate & Copy" button again after you have made your changes.
84+
</p>
85+
<ul id="courseAdjustList"></ul>
86+
</div>
8687

8788
<h3>Contribute / Suggest</h3>
8889
If you want to suggest something create an issue at

0 commit comments

Comments
 (0)