├── .gitignore ├── image.png ├── internal ├── static │ ├── showcase.png │ ├── style.css │ ├── index.html │ └── main.js ├── testdata │ ├── nameshortening.ics │ ├── coursefiltering.ics │ ├── location.ics │ ├── duplication.ics │ └── timeadjustment.ics ├── courses.json ├── app_test.go ├── app.go └── buildings.json ├── cmd ├── proxy │ └── proxy.go └── healthcheck │ └── healthcheck.go ├── docker-compose.local.yaml ├── SECURITY.md ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── deploy.yml ├── docker-compose.yml ├── Dockerfile ├── README.md ├── go.mod ├── go.sum └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TUM-Dev/CalendarProxy/HEAD/image.png -------------------------------------------------------------------------------- /internal/static/showcase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TUM-Dev/CalendarProxy/HEAD/internal/static/showcase.png -------------------------------------------------------------------------------- /cmd/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/tum-dev/calendar-proxy/internal" 5 | "log" 6 | ) 7 | 8 | func main() { 9 | app := &internal.App{} 10 | log.Println(app.Run()) 11 | } 12 | -------------------------------------------------------------------------------- /docker-compose.local.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | cal-proxy: 3 | build: . 4 | ports: 5 | - 4321:4321 6 | restart: always 7 | # security 8 | read_only: true 9 | user: "1000:3000" 10 | privileged: false 11 | cap_drop: 12 | - ALL -------------------------------------------------------------------------------- /cmd/healthcheck/healthcheck.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | _, err := http.Get("http://127.0.0.1:4321/health") 11 | if err != nil { 12 | log.Printf("Healthcheck failed: %s\n", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please report any and all security vulnerabilitys you found, or you think you found at app (at-symbol) tum.de . 6 | We will diagnose the issue internally and propose a fix. 7 | The timeline of a fix depends on how severe the problem is and what impacts it has. 8 | As a reward for reporting such vulnerabilitys you can get exclusive stickers or other small things. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "docker" 8 | directory: "/" # Location of package manifests 9 | schedule: 10 | interval: "monthly" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build and test a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.24 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | calendarproxy: 3 | image: ghcr.io/tum-dev/calendarproxy/server:latest 4 | restart: unless-stopped 5 | labels: 6 | - "traefik.enable=true" 7 | - "traefik.http.routers.calendarproxy.entrypoints=webs" 8 | - "traefik.http.routers.calendarproxy.tls.certresolver=leacme" 9 | - "traefik.http.routers.calendarproxy.rule=Host(`cal.tum.app`) || Host(`cal.tum.sexy`)" 10 | - "traefik.http.services.calendarproxy.loadbalancer.server.port=4321" 11 | 12 | networks: 13 | - traefik_traefik 14 | # security 15 | read_only: true 16 | user: "1000:3000" 17 | privileged: false 18 | cap_drop: 19 | - ALL 20 | 21 | networks: 22 | traefik_traefik: 23 | external: true # comment out for local use 24 | -------------------------------------------------------------------------------- /internal/testdata/nameshortening.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | X-WR-TIMEZONE:Europe/Vienna 6 | X-PUBLISHED-TTL:PT1H0M 7 | PRODID:-//Technische Universität München//DE 8 | X-WR-CALNAME:Demo Name 9 | X-WR-CALDESC:Demo Name @ Technische Universität München 10 | BEGIN:VEVENT 11 | UID:889438018@tum.de 12 | DTSTAMP:20230109T204228Z 13 | STATUS:CONFIRMED 14 | CLASS:PUBLIC 15 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 16 | 0540 17 | SUMMARY:0000002467 Einführung in die Rechnerarchitektur (IN0004) VO\, Standardgruppe 18 | DESCRIPTION:fix\; Abhaltung\; 19 | DTSTART:20230113T120000Z 20 | DTEND:20230113T140000Z 21 | LOCATION:MW 1801\, Ernst-Schmidt-Hörsaal (5508.02.801) 22 | X-CO-RECURRINGID:528560 23 | END:VEVENT 24 | END:VCALENDAR 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine as builder 2 | 3 | # Ca-certificates are required to call HTTPS endpoints. 4 | RUN apk update && apk add --no-cache ca-certificates tzdata alpine-sdk bash && update-ca-certificates 5 | 6 | # Create appuser 7 | RUN adduser -D -g '' appuser 8 | WORKDIR /app 9 | # Copy go mod and sum files and download dependencies 10 | COPY go.mod go.sum ./ 11 | RUN go mod download 12 | 13 | # compile the app 14 | COPY cmd cmd 15 | COPY internal internal 16 | 17 | # bundle version into binary if specified in build-args, dev otherwise. 18 | ARG version=dev 19 | # Compile statically 20 | RUN CGO_ENABLED=0 go build -ldflags "-w -extldflags '-static' -X internal/app.Version=${version}" -o /proxy cmd/proxy/proxy.go 21 | RUN CGO_ENABLED=0 go build -ldflags "-w -extldflags '-static'" -o /healthcheck cmd/healthcheck/healthcheck.go 22 | 23 | FROM scratch 24 | 25 | COPY --from=builder /proxy /proxy 26 | COPY --from=builder /healthcheck /healthcheck 27 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 28 | 29 | EXPOSE 4321 30 | HEALTHCHECK --interval=1s --timeout=1s --start-period=2s --retries=3 CMD [ "/healthcheck" ] 31 | 32 | CMD ["/proxy"] 33 | -------------------------------------------------------------------------------- /internal/testdata/coursefiltering.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | X-WR-TIMEZONE:Europe/Vienna 6 | X-PUBLISHED-TTL:PT1H0M 7 | PRODID:-//Technische Universität München//DE 8 | X-WR-CALNAME:Demo Name 9 | X-WR-CALDESC:Demo Name @ Technische Universität München 10 | BEGIN:VEVENT 11 | UID:889438018@tum.de 12 | DTSTAMP:20230109T204228Z 13 | STATUS:CONFIRMED 14 | CLASS:PUBLIC 15 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 16 | 0540 17 | SUMMARY:Einführung in die Rechnerarchitektur (IN0004) VO\, Standardgruppe 18 | DESCRIPTION:fix\; Abhaltung\; 19 | DTSTART:20230113T120000Z 20 | DTEND:20230113T140000Z 21 | LOCATION:MW 1801\, Ernst-Schmidt-Hörsaal (5508.02.801) 22 | X-CO-RECURRINGID:528560 23 | END:VEVENT 24 | BEGIN:VEVENT 25 | UID:889528865@tum.de 26 | DTSTAMP:20230109T204228Z 27 | STATUS:CONFIRMED 28 | CLASS:PUBLIC 29 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 30 | 0540 31 | SUMMARY:Another Lecture(CIT3456) VO\, Standardgruppe 32 | DESCRIPTION:fix\; Abhaltung\; 33 | DTSTART:20230113T120000Z 34 | DTEND:20230113T140000Z 35 | GEO:48.26736;11.67312 36 | LOCATION:MI HS 1 37 | X-CO-RECURRINGID:532235 38 | END:VEVENT 39 | END:VCALENDAR 40 | -------------------------------------------------------------------------------- /internal/testdata/location.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | X-WR-TIMEZONE:Europe/Vienna 6 | X-PUBLISHED-TTL:PT1H0M 7 | PRODID:-//Technische Universität München//DE 8 | X-WR-CALNAME:Demo Name 9 | X-WR-CALDESC:Demo Name @ Technische Universität München 10 | BEGIN:VEVENT 11 | UID:889438018@tum.de 12 | DTSTAMP:20230109T204228Z 13 | STATUS:CONFIRMED 14 | CLASS:PUBLIC 15 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 16 | 0540 17 | SUMMARY:Einführung in die Rechnerarchitektur (IN0004) VO\, Standardgruppe 18 | DESCRIPTION:fix\; Abhaltung\; 19 | DTSTART:20230113T120000Z 20 | DTEND:20230113T140000Z 21 | LOCATION:MW 1801\, Ernst-Schmidt-Hörsaal (5508.02.801) 22 | X-CO-RECURRINGID:528560 23 | END:VEVENT 24 | BEGIN:VEVENT 25 | UID:889528865@tum.de 26 | DTSTAMP:20230109T204228Z 27 | STATUS:CONFIRMED 28 | CLASS:PUBLIC 29 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 30 | 0540 31 | SUMMARY:Einführung in die Rechnerarchitektur (IN0004) VO\, Standardgruppe 32 | DESCRIPTION:fix\; Abhaltung\; 33 | DTSTART:20230113T120000Z 34 | DTEND:20230113T140000Z 35 | GEO:48.26736;11.67312 36 | LOCATION:MI HS 1 37 | X-CO-RECURRINGID:532235 38 | END:VEVENT 39 | END:VCALENDAR 40 | -------------------------------------------------------------------------------- /internal/testdata/duplication.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | X-WR-TIMEZONE:Europe/Vienna 6 | X-PUBLISHED-TTL:PT1H0M 7 | PRODID:-//Technische Universität München//DE 8 | X-WR-CALNAME:Demo Name 9 | X-WR-CALDESC:Demo Name @ Technische Universität München 10 | BEGIN:VEVENT 11 | UID:889438018@tum.de 12 | DTSTAMP:20230109T204228Z 13 | STATUS:CONFIRMED 14 | CLASS:PUBLIC 15 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 16 | 0540 17 | SUMMARY:Einführung in die Rechnerarchitektur (IN0004) VO\, Standardgruppe 18 | DESCRIPTION:fix\; Abhaltung\; 19 | DTSTART:20230113T120000Z 20 | DTEND:20230113T140000Z 21 | LOCATION:MW 1801\, Ernst-Schmidt-Hörsaal (5508.02.801) 22 | X-CO-RECURRINGID:528560 23 | END:VEVENT 24 | BEGIN:VEVENT 25 | UID:889528865@tum.de 26 | DTSTAMP:20230109T204228Z 27 | STATUS:CONFIRMED 28 | CLASS:PUBLIC 29 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95063 30 | 0540 31 | SUMMARY:Einführung in die Rechnerarchitektur (IN0004) VO\, Standardgruppe 32 | DESCRIPTION:fix\; Abhaltung\; 33 | DTSTART:20230113T120000Z 34 | DTEND:20230113T140000Z 35 | GEO:48.26736;11.67312 36 | LOCATION:MI HS 1 37 | X-CO-RECURRINGID:532235 38 | END:VEVENT 39 | END:VCALENDAR 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TUM Calendar Proxy 2 | 3 | ![Alt text](image.png) 4 | 5 | This is a proxy service that simplifies and enhances the iCal export from TUM Online. It allows you to: 6 | 7 | - Shorten long lesson names, such as 'Grundlagen Betriebssysteme und Systemsoftware' → 'GBS' 8 | - Add locations that are recognized by Google / Apple Maps 9 | - Filter out unwanted events, such as cancelled, duplicate or optional ones 10 | 11 | You can use the proxy service by visiting and following the instructions there. 12 | 13 | ## Development 14 | If you want to run the proxy service locally or contribute to the project, you will need: 15 | 16 | - Go 1.22 or higher 17 | - Docker (optional) 18 | 19 | To run the service locally, follow these steps: 20 | 21 | - Clone this repository 22 | ```sh 23 | git clone https://github.com/TUM-Dev/CalendarProxy.git 24 | ``` 25 | - Navigate to the project directory: 26 | ```sh 27 | cd CalendarProxy 28 | ``` 29 | - Run the proxy server: 30 | ```sh 31 | go run cmd/proxy/proxy.go 32 | ``` 33 | - The service will be available at 34 | 35 | To build an image using Docker, follow these steps: 36 | 37 | - ```sh 38 | docker compose -f docker-compose.local.yaml up --build 39 | ``` 40 | - The service will be available at 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tum-dev/calendar-proxy 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/arran4/golang-ical v0.3.2 7 | github.com/getsentry/sentry-go v0.40.0 8 | github.com/getsentry/sentry-go/gin v0.40.0 9 | github.com/gin-gonic/gin v1.11.0 10 | ) 11 | 12 | require ( 13 | github.com/bytedance/sonic v1.14.0 // indirect 14 | github.com/bytedance/sonic/loader v0.3.0 // indirect 15 | github.com/cloudwego/base64x v0.1.6 // indirect 16 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 17 | github.com/gin-contrib/sse v1.1.0 // indirect 18 | github.com/go-playground/locales v0.14.1 // indirect 19 | github.com/go-playground/universal-translator v0.18.1 // indirect 20 | github.com/go-playground/validator/v10 v10.27.0 // indirect 21 | github.com/goccy/go-json v0.10.2 // indirect 22 | github.com/goccy/go-yaml v1.18.0 // indirect 23 | github.com/json-iterator/go v1.1.12 // indirect 24 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 25 | github.com/leodido/go-urn v1.4.0 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 28 | github.com/modern-go/reflect2 v1.0.2 // indirect 29 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 30 | github.com/quic-go/qpack v0.5.1 // indirect 31 | github.com/quic-go/quic-go v0.54.1 // indirect 32 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 33 | github.com/ugorji/go/codec v1.3.0 // indirect 34 | go.uber.org/mock v0.5.0 // indirect 35 | golang.org/x/arch v0.20.0 // indirect 36 | golang.org/x/crypto v0.45.0 // indirect 37 | golang.org/x/mod v0.29.0 // indirect 38 | golang.org/x/net v0.47.0 // indirect 39 | golang.org/x/sync v0.18.0 // indirect 40 | golang.org/x/sys v0.38.0 // indirect 41 | golang.org/x/text v0.31.0 // indirect 42 | golang.org/x/tools v0.38.0 // indirect 43 | google.golang.org/protobuf v1.36.9 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /internal/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Segoe UI", Arial, sans-serif; 3 | } 4 | 5 | h1, h2 { 6 | font-family: "Segoe UI", Arial, sans-serif; 7 | font-weight: 400; 8 | margin: 10px 0; 9 | } 10 | 11 | h2 { 12 | font-size: 32px; 13 | } 14 | 15 | h1 { 16 | font-size: 42px; 17 | } 18 | 19 | #tumCalLink, #generateLinkBtn { 20 | width: 100%; 21 | margin: 0; 22 | border: 2px solid #aaa; 23 | } 24 | 25 | #tumCalLink { 26 | border: 2px solid #aaa; 27 | } 28 | 29 | #generateLinkBtn { 30 | border: 2px solid #007cea; 31 | } 32 | 33 | .main { 34 | flex-shrink: 0 !important; 35 | } 36 | 37 | .container { 38 | width: auto; 39 | max-width: 680px; 40 | padding: 0 15px; 41 | margin-right: auto; 42 | margin-left: auto; 43 | } 44 | 45 | img { 46 | width: 100%; 47 | } 48 | 49 | .inputWrapper { 50 | margin-top: 8px; 51 | margin-bottom: 8px; 52 | } 53 | 54 | @media (min-width: 576px) { 55 | .container { 56 | max-width: 540px; 57 | } 58 | 59 | img { 60 | width: auto; 61 | } 62 | 63 | #generateLinkBtn { 64 | width: fit-content; 65 | } 66 | 67 | #tumCalLink { 68 | width: -moz-fit-content; 69 | } 70 | 71 | .inputWrapper { 72 | display: flex; 73 | } 74 | } 75 | 76 | @media (min-width: 768px) { 77 | .container { 78 | max-width: 720px; 79 | !important; 80 | } 81 | } 82 | 83 | input[type=url] { 84 | border-radius: 0; 85 | font-size: medium; 86 | padding: 12px 20px; 87 | margin: 8px 0; 88 | box-sizing: border-box; 89 | } 90 | 91 | input[type=text]:focus { 92 | border: 2px solid #555; 93 | } 94 | 95 | .invalid { 96 | border: 2px solid red !important; 97 | } 98 | 99 | button { 100 | font-size: medium; 101 | background-color: #007cea; 102 | border: none; 103 | color: white; 104 | padding: 16px 32px; 105 | text-decoration: none; 106 | margin: 4px 2px; 107 | cursor: pointer; 108 | } 109 | -------------------------------------------------------------------------------- /internal/testdata/timeadjustment.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | X-WR-TIMEZONE:Europe/Vienna 6 | X-PUBLISHED-TTL:PT1H0M 7 | PRODID:-//Technische Universität München//DE 8 | X-WR-CALNAME:Demo Name 9 | X-WR-CALDESC:Demo Name@ Technische Universität München 10 | BEGIN:VEVENT 11 | UID:890763095@tum.de 12 | DTSTAMP:20240205T143746Z 13 | STATUS:CONFIRMED 14 | CLASS:PUBLIC 15 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95069 16 | 8580 17 | SUMMARY:Practical Course: Open Source Lab (IN0012\, IN2106\, IN4308) PR\, 18 | Standardgruppe 19 | DESCRIPTION:fix\; Abhaltung\; Presentation Session\; 20 | DTSTART:20240109T170000Z 21 | DTEND:20240109T190000Z 22 | LOCATION:Online: Videokonferenz\nZoom etc. 23 | X-CO-RECURRINGID:583745 24 | END:VEVENT 25 | BEGIN:VEVENT 26 | UID:890763084@tum.de 27 | DTSTAMP:20240205T143746Z 28 | STATUS:CONFIRMED 29 | CLASS:PUBLIC 30 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95069 31 | 8580 32 | SUMMARY:Practical Course: Open Source Lab (IN0012\, IN2106\, IN4308) PR\, 33 | Standardgruppe 34 | DESCRIPTION:fix\; Abhaltung\; Presentation Session\; 35 | DTSTART:20231113T170000Z 36 | DTEND:20231113T190000Z 37 | LOCATION:Online: Videokonferenz\nZoom etc. 38 | X-CO-RECURRINGID:583744 39 | END:VEVENT 40 | BEGIN:VEVENT 41 | UID:890763080@tum.de 42 | DTSTAMP:20240205T143746Z 43 | STATUS:CONFIRMED 44 | CLASS:PUBLIC 45 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95069 46 | 8580 47 | SUMMARY:Practical Course: Open Source Lab (IN0012\, IN2106\, IN4308) PR\, 48 | Standardgruppe 49 | DESCRIPTION:fix\; Abhaltung\; Open Source\, Open Source DEMO agenda\; 50 | DTSTART:20231023T160000Z 51 | DTEND:20231023T180000Z 52 | LOCATION:Online: Videokonferenz\nZoom etc. 53 | X-CO-RECURRINGID: 54 | END:VEVENT 55 | BEGIN:VEVENT 56 | UID:890763097@tum.de 57 | DTSTAMP:20240205T143746Z 58 | STATUS:CONFIRMED 59 | CLASS:PUBLIC 60 | URL:https://campus.tum.de/tumonline/wbLv.wbShowLVDetail?pStpSpNr=95069 61 | 8580 62 | SUMMARY:Practical Course: Open Source Lab (IN0012\, IN2106\, IN4308) PR\, 63 | Standardgruppe 64 | DESCRIPTION:fix\; Abhaltung\; Presentation Session\; 65 | DTSTART:20240206T170000Z 66 | DTEND:20240206T190000Z 67 | LOCATION:Online: Videokonferenz\nZoom etc. 68 | X-CO-RECURRINGID:583745 69 | END:VEVENT 70 | END:VCALENDAR 71 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ secrets.IMAGE_NAME }} 14 | 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | packages: write 23 | # This is used to complete the identity challenge 24 | # with sigstore/fulcio when running outside of PRs. 25 | id-token: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v3 33 | 34 | - name: Set up Docker Buildx 35 | uses: docker/setup-buildx-action@v3 36 | 37 | # Login against a Docker registry except on PR 38 | # https://github.com/docker/login-action 39 | - name: Log into registry ${{ env.REGISTRY }} 40 | if: github.event_name != 'pull_request' 41 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef 42 | with: 43 | registry: ${{ env.REGISTRY }} 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | # Extract metadata (tags, labels) for Docker 48 | # https://github.com/docker/metadata-action 49 | - name: Extract Docker metadata 50 | id: meta 51 | uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 52 | with: 53 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 54 | flavor: | 55 | latest=true 56 | prefix= 57 | suffix= 58 | # Build and push Docker image with Buildx (don't push on PR) 59 | # https://github.com/docker/build-push-action 60 | - name: Build and push Docker image 61 | id: build-and-push 62 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 63 | with: 64 | context: . 65 | push: ${{ github.event_name != 'pull_request' }} 66 | build-args: version=${{ github.sha }} 67 | tags: ${{ steps.meta.outputs.tags }} 68 | labels: ${{ steps.meta.outputs.labels }} 69 | platforms: linux/amd64,linux/arm64 70 | cache-from: type=gha 71 | cache-to: type=gha,mode=max 72 | -------------------------------------------------------------------------------- /internal/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 15 | 16 | 17 | TUM Calendar Proxy - clean, structured calendar entries 18 | 19 | 20 | Contribute on GitHub 27 |
28 |
29 |

TUM Calendar Proxy

30 | New and old representation of the calender 34 | 35 |

About

36 |

37 | Nice and easy proxy to remove some clutter from the TUM online iCal 38 | export. E.g.: 39 |

40 |
    41 |
  • 42 | Shorten Lesson Names like 'Grundlagen Betriebssysteme und 43 | Systemsoftware' → 'GBS' 44 |
  • 45 |
  • 46 | Adds locations, which are understood by Google Maps / Google Now 47 |
  • 48 |
  • Replaces 'Tutorübung' with 'TÜ'
  • 49 |
  • Remove event duplicates due to multiple rooms
  • 50 |
  • Allows you to hide courses in your calendar without de-registering in TUMOnline
  • 51 |
52 | 53 |

HowTo

54 |
    55 |
  1. 56 | Navigate to your calendar in TUMOnline and grab the URL via the 57 | 'Veröffentlichen' button 58 |
  2. 59 |
  3. 60 | 61 |
    62 | 68 | 71 |
    72 |
  4. 73 |
  5. The link is now copied to your clipboard!
  6. 74 |
  7. Profit!
  8. 75 |
  9. 76 | Go to Google Calendar (or similar) and import the resulting url 77 |
  10. 78 |
79 | 80 | 87 | 88 |

Contribute / Suggest

89 | If you want to suggest something create an issue at 90 | GitHub 91 | 92 |
93 |
94 | 95 | Version v2.0 - 97 | Changelog 101 |
102 |
103 | 104 | 105 | -------------------------------------------------------------------------------- /internal/static/main.js: -------------------------------------------------------------------------------- 1 | const hiddenCourses = new Set(); 2 | let originalLink = null; 3 | 4 | function getAndCheckCalLink() { 5 | let input = document.getElementById("tumCalLink") 6 | input.removeAttribute("class") 7 | let value = input.value; 8 | if (originalLink !== null) { 9 | value = originalLink; 10 | } 11 | if (!value.match(/https:\/\/campus\.tum\.de\/tumonlinej\/.{2}\/termin\/ical\?(pStud|pPers)=[0-9,A-Z]*&pToken=[0-9,A-Z]*/i) 12 | && !value.match(/https:\/\/cal\.tum\.(app|sexy)\/\?(pStud|pPers)=[0-9,A-Z]*&pToken=[0-9,A-Z]*/i)) { 13 | input.setAttribute("class", "invalid") 14 | return undefined; 15 | } 16 | 17 | return value; 18 | } 19 | 20 | function setCopyButton(state /* copied | reset */) { 21 | const btn = document.getElementById("generateLinkBtn"); 22 | 23 | const isCopiedState = state === "copied"; 24 | btn.innerText = isCopiedState ? "copied!" : "Generate & Copy"; 25 | btn.setAttribute("style", `background-color: ${isCopiedState ? "#4CAF50" : "#007cea"};`); 26 | } 27 | 28 | function generateLink() { 29 | const calLink = getAndCheckCalLink(); 30 | if (!calLink) 31 | return; 32 | 33 | const adjustedLink = new URL(calLink.replace(/https:\/\/campus.tum.de\/tumonlinej\/.{2}\/termin\/ical/i, "https://cal.tum.app").replace("\t", "")); 34 | 35 | // add course hide option 36 | const queryParams = new URLSearchParams(adjustedLink.search); 37 | for (const courseName of hiddenCourses) { 38 | queryParams.append("hide", courseName); 39 | } 40 | 41 | adjustedLink.search = queryParams; 42 | copyToClipboard(adjustedLink.toString()); 43 | setCopyButton("copied"); 44 | 45 | originalLink = calLink; 46 | document.getElementById("tumCalLink").value = adjustedLink.toString(); 47 | } 48 | 49 | function reloadCourses() { 50 | originalLink = null; 51 | const calLink = getAndCheckCalLink(); 52 | if (!calLink) 53 | return; 54 | 55 | // includes pStud and pToken 56 | const queryParams = new URLSearchParams(new URL(calLink).search); 57 | const url = new URL("api/courses", window.location.origin); 58 | url.search = queryParams; 59 | 60 | fetch(url) 61 | .then(response => { 62 | if (response.ok) { 63 | return response.json(); 64 | } 65 | 66 | throw new Error(`Failed to fetch courses: ${response.text()}`); 67 | }) 68 | .then(courses => { 69 | // add checkboxes for each course in courseAdjustList 70 | const courseAdjustList = document.getElementById("courseAdjustList"); 71 | courseAdjustList.innerHTML = ""; 72 | 73 | for (const [key, course] of Object.entries(courses)) { 74 | const li = document.createElement("li"); 75 | const input = document.createElement("input"); 76 | input.type = "checkbox"; 77 | input.id = course.summary; 78 | input.checked = !course.hide; 79 | input.onchange = () => { 80 | if (input.checked) { 81 | hiddenCourses.delete(key); 82 | } else { 83 | hiddenCourses.add(key); 84 | } 85 | setCopyButton("reset"); 86 | }; 87 | li.appendChild(input); 88 | li.appendChild(document.createTextNode(course.summary)); 89 | courseAdjustList.appendChild(li); 90 | } 91 | 92 | // enable/disable course adjustment section depending on whether courses were found 93 | document.getElementById("courseAdjustDiv").hidden = Object.keys(courses).length === 0; 94 | }) 95 | .catch(err => { 96 | console.log(err); 97 | document.getElementById("courseAdjustDiv").hidden = true; 98 | }); 99 | } 100 | 101 | function copyToClipboard(text) { 102 | const dummy = document.createElement("textarea"); 103 | document.body.appendChild(dummy); 104 | dummy.value = text; 105 | dummy.select(); 106 | document.execCommand("copy"); 107 | document.body.removeChild(dummy); 108 | } 109 | -------------------------------------------------------------------------------- /internal/courses.json: -------------------------------------------------------------------------------- 1 | { 2 | "Technology and Innovation Management": "TIM", 3 | "Tutorübungen": "TÜ", 4 | "Überfachliche Grundlagen": "ÜG", 5 | "Grundlagen": "G", 6 | "Introduction": "I", 7 | "Datenbanken": "DB", 8 | "Einsatz und Realisierung von Datenbanksystemen": "ERDB", 9 | "Zentralübungen": "ZÜ", 10 | "Zentralübung": "ZÜ", 11 | "Vertiefungsübungen": "VÜ", 12 | "Übungen": "Ü", 13 | "Übung": "Ü", 14 | "Exercise": "EX", 15 | "Exercises": "EX", 16 | "Anlagen-Zentralübung": "ZÜ", 17 | "Kleingruppenübung": "KGÜ", 18 | "Vertiefungsübung": "VÜ", 19 | "Vorlesung": "VL", 20 | "Gruppenübung": "GÜ", 21 | "Tutorübung": "TÜ", 22 | "Software Engineering für betriebliche Anwendungen - Bachelorkurs": "SEBA", 23 | "Software Engineering für betriebliche Anwendungen - Masterkurs: Web Application Engineering": "SEBA", 24 | "Betriebswirtschaftslehre": "BWL", 25 | "Volkswirtschaftslehre": "VWL", 26 | "Wirtschaftsprivatrecht 1": "WPR1", 27 | "Wirtschaftsprivatrecht 2": "WPR2", 28 | "Wirtschaftsprivatrecht": "WPR", 29 | "Funktionale Programmierung und Verifikation": "FPV", 30 | "Buchführung und Rechnungswesen": "BF & RW", 31 | "Planen und Entscheiden in betrieblichen Informationssystemen - Wirtschaftsinformatik 4": "PLEBIS", 32 | "Planen und Entscheiden in betrieblichen Informationssystemen": "PLEBIS", 33 | "Statistics for Business Administration (with Introduction to R)": "Stats", 34 | "Kostenrechnung für Wirtschaftsinformatik und Nebenfach": "KR", 35 | "Kostenrechnung": "KR", 36 | "Mathematische Behandlung der Natur- und Wirtschaftswissenschaften (Mathematik 1)": "MBNW", 37 | "Einführung in die Wirtschaftsinformatik": "WINFO", 38 | "Projektorganisation und -management in der Softwaretechnik": "POM", 39 | "Empirical Research Methods": "ERM", 40 | "Informationsmanagement": "IM", 41 | "Business Process Technologies and Management": "BPTM", 42 | "Bachelor-Seminar: Digitale Hochschule: Aktuelle Trends und Herausforderungen": "Digitale Hochschule", 43 | "Betriebssysteme und Systemsoftware": "BS", 44 | "Einführung in die Informatik 2": "Einführung in die Informatik 2", 45 | "Einführung in die Informatik": "EIDI", 46 | "Praktikum: Grundlagen der Programmierung": "PGdP", 47 | "Einführung in die Rechnerarchitektur": "ERA", 48 | "Grundlagenpraktikum: Rechnerarchitektur": "GRA", 49 | "Einführung in die Softwaretechnik": "EIST", 50 | "Grundlagen: Algorithmen und Datenstrukturen": "GAD", 51 | "Effiziente Algorithmen und Datenstrukturen": "EAD", 52 | "Grundlagen: Rechnernetze und Verteilte Systeme": "GRNVS", 53 | "Rechnernetze und Verteilte Systeme": "RNVS", 54 | "Einführung in die Theoretische Informatik": "Theo", 55 | "Diskrete Strukturen": "DS", 56 | "Diskrete Wahrscheinlichkeitstheorie": "DWT", 57 | "Numerisches Programmieren": "NumProg", 58 | "Modellbildung und Simulation": "ModSim", 59 | "(Fokus Analysis)": "(Ana)", 60 | "Lineare Algebra für Informatik": "LinAlg", 61 | "Analysis für Informatik": "Analysis", 62 | " der Künstlichen Intelligenz": "KI", 63 | "Advanced Topics of Software Engineering": "ASE", 64 | "Praktikum - iPraktikum, iOS Praktikum": "iPraktikum", 65 | "B1.1+B1.2 (intensiv)": "B1", 66 | "Business Analytics and Machine Learning": "BA & ML", 67 | "Netzsicherheit": "NetSec", 68 | "Management Accounting": "MA", 69 | "Advanced Seminar Finance & Accounting": "Seminar F&A", 70 | "Advanced Topics in Finance & Accounting": "Topics F&A", 71 | "Maschinelles Lernen": "ML", 72 | " to Deep Learning": "2DL", 73 | "Security Engineering": "SecE", 74 | "Peer-to-Peer-Systeme und Sicherheit": "P2PSec", 75 | "Requirements Engineering": "ReqE", 76 | "Functional Data Structures": "FDS", 77 | "Hardware Security": "HWSec", 78 | "Algorithmic Game Theory": "AGT", 79 | "Fortgeschrittene Themen des Softwaretests": "AdvTest", 80 | "Technische Mechanik I": "TM 1", 81 | "Technische Mechanik II": "TM 2", 82 | "Technische Mechanik": "TM", 83 | "Technischen Elektrizitätslehre": "TE", 84 | "Regelungstechnik": "RT", 85 | "Fluidmechanik I": "FM 1", 86 | "Fluidmechanik": "FM", 87 | "Maschinenelemente": "ME", 88 | "Werkstoffkunde": "WK", 89 | "Mathematik 1 für": "M1", 90 | "Mathematik 2 für": "M2", 91 | "Mathematik 3 für": "M3", 92 | "Technische Thermodynamik": "TTD", 93 | "Automatisierungstechnik 1": "AT", 94 | "Einführung in die Werkstoffe und Fertigungstechnologien von Carbon Composites": "CC", 95 | "Wärmetransportphänomene": "WTP", 96 | "Maschinenzeichnen": "MZ", 97 | "Informatikanwendungen in der Medizin": "CAMP", 98 | "Computer Vision": "CV", 99 | "Natural Language Processing": "NLP", 100 | "Augmented Reality": "AR", 101 | "Erweiterte Realität": "AR", 102 | "- Regeln des technischen Zeichnens (CAMPP)": "", 103 | " der modernen Informationstechnik I ": "dmIT 1", 104 | " der modernen Informationstechnik": "dmIT", 105 | "Modellierung von Unsicherheiten und Daten im Maschinenwesen": "MUD", 106 | "Mathematische Tools": "MTT", 107 | "Spanende Fertigungsverfahren": "SFV", 108 | "Hausaufgabentutorium": "HA TÜ", 109 | "Kurs zum/zur Fachsanitäter*in": "Fachsani", 110 | "Entrepreneurship for Students of Information Systems": "EShip", 111 | " ": " ", 112 | "&": "&", 113 | "IT und Unternehmensberatung": "ITUB", 114 | "Data Analytics in Applications ": "DAiA", 115 | "Strategisches IT-Management": "Strat. IT Mgmt.", 116 | "Fundamentals of Artificial Intelligence": "Fund. of AI", 117 | "Skizzier- und Darstellungstechniken": "SKDT" 118 | } 119 | -------------------------------------------------------------------------------- /internal/app_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | ics "github.com/arran4/golang-ical" 10 | ) 11 | 12 | func getTestData(t *testing.T, name string) (string, *App) { 13 | f, err := os.Open("testdata/" + name) 14 | if err != nil { 15 | t.Fatal("can't open testdata") 16 | } 17 | all, err := io.ReadAll(f) 18 | if err != nil { 19 | t.Fatal("can't read testdata") 20 | } 21 | app, err := newApp() 22 | if err != nil { 23 | t.Fatal("can't create test subject", err) 24 | } 25 | return string(all), app 26 | } 27 | 28 | func TestReplacement(t *testing.T) { 29 | r1 := Replacement{"b", "b"} 30 | if r1.isLessThan(&r1) { 31 | t.Error("Replacement should not be less than itself") 32 | return 33 | } 34 | if r1.isLessThan(&Replacement{key: "longer key first"}) { 35 | t.Error("Replacement should sort longer prefix first") 36 | return 37 | } 38 | if !r1.isLessThan(&Replacement{key: ""}) { 39 | t.Error("Replacement should sort longer prefix first") 40 | return 41 | } 42 | if r1.isLessThan(&Replacement{key: "a"}) { 43 | t.Error("Replacement should sort alphabetically") 44 | return 45 | } 46 | if !r1.isLessThan(&Replacement{key: "c"}) { 47 | t.Error("Replacement should sort alphabetically") 48 | return 49 | } 50 | if r1.isLessThan(&Replacement{key: r1.key, value: "a"}) { 51 | t.Error("Replacement with equal key should sort alphabetically by value") 52 | return 53 | } 54 | if !r1.isLessThan(&Replacement{key: r1.key, value: "c"}) { 55 | t.Error("Replacement with equal key should sort alphabetically by value") 56 | return 57 | } 58 | 59 | } 60 | 61 | func TestDeduplication(t *testing.T) { 62 | testData, app := getTestData(t, "duplication.ics") 63 | calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{}) 64 | if err != nil { 65 | t.Error(err) 66 | return 67 | } 68 | if len(calendar.Components) != 1 { 69 | t.Errorf("Calendar should have only 1 entry after deduplication but has %d", len(calendar.Components)) 70 | return 71 | } 72 | 73 | // Verify that the additional room from the deduplicated event is in the description 74 | desc := calendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertyDescription).Value 75 | if !strings.Contains(desc, "Additional rooms:") { 76 | t.Error("Description should contain 'Additional rooms:' when events are deduplicated with different locations") 77 | return 78 | } 79 | if !strings.Contains(desc, "MI HS 1") { 80 | t.Error("Description should contain the additional room 'MI HS 1' from the deduplicated event") 81 | return 82 | } 83 | } 84 | 85 | func TestMultipleRooms(t *testing.T) { 86 | // Setup app with building replacements 87 | app, err := newApp() 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | 92 | // Create a dummy event with multiple rooms 93 | event := ics.NewEvent("test-uid") 94 | 95 | // Using 5508 and 5612 which are present in buildings.json 96 | // 5508 -> Boltzmannstr. 15, 85748 Garching b. München 97 | // 5612 -> Boltzmannstr. 3, 85748 Garching b. München 98 | 99 | // Construct a location string with multiple rooms. 100 | // RoomA, DescA (5508.01.001), RoomB, DescB (5612.01.001) 101 | 102 | location := "RoomA, DescA (5508.01.001), RoomB, DescB (5612.01.001)" 103 | event.SetProperty(ics.ComponentPropertyLocation, location) 104 | event.SetProperty(ics.ComponentPropertySummary, "Test Event") 105 | event.SetProperty(ics.ComponentPropertyDescription, "Original Description") 106 | event.SetProperty(ics.ComponentPropertyStatus, "CONFIRMED") 107 | 108 | app.cleanEvent(event, []string{}) 109 | 110 | desc := event.GetProperty(ics.ComponentPropertyDescription).Value 111 | loc := event.GetProperty(ics.ComponentPropertyLocation).Value 112 | 113 | // Check if both rooms are present in description or nav links 114 | if !strings.Contains(desc, "5508.01.001") { 115 | t.Errorf("Description should contain first room ID") 116 | } 117 | if !strings.Contains(desc, "5612.01.001") { 118 | t.Errorf("Description should contain second room ID") 119 | } 120 | 121 | // Check if nav links are generated for both 122 | // 5508.01.001 -> https://nav.tum.de/room/5508.01.001 123 | // 5612.01.001 -> https://nav.tum.de/room/5612.01.001 124 | 125 | if !strings.Contains(desc, "https://nav.tum.de/room/5508.01.001") { 126 | t.Error("Missing nav link for first room") 127 | } 128 | if !strings.Contains(desc, "https://nav.tum.de/room/5612.01.001") { 129 | t.Error("Missing nav link for second room") 130 | } 131 | 132 | // With non-greedy regex, the location should be the first building (5508) 133 | expectedLoc := "Boltzmannstr. 15, 85748 Garching b. München" 134 | if loc != expectedLoc { 135 | t.Errorf("Location should be %s but is %s", expectedLoc, loc) 136 | } 137 | } 138 | 139 | func TestNameShortening(t *testing.T) { 140 | testData, app := getTestData(t, "nameshortening.ics") 141 | calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{}) 142 | if err != nil { 143 | t.Error(err) 144 | return 145 | } 146 | summary := calendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertySummary).Value 147 | if summary != "ERA" { 148 | t.Errorf("Einführung in die Rechnerarchitektur (IN0004) VO, Standardgruppe should be shortened to ERA but is %s", summary) 149 | return 150 | } 151 | } 152 | 153 | func TestLocationReplacement(t *testing.T) { 154 | testData, app := getTestData(t, "location.ics") 155 | calendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{}) 156 | if err != nil { 157 | t.Error(err) 158 | return 159 | } 160 | location := calendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertyLocation).Value 161 | expectedLocation := "Boltzmannstr. 15, 85748 Garching b. München" 162 | if location != expectedLocation { 163 | t.Errorf("Location should be shortened to %s but is %s", expectedLocation, location) 164 | return 165 | } 166 | desc := calendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertyDescription).Value 167 | expectedDescription := "Additional rooms:\nMI HS 1\n\nhttps://nav.tum.de/room/5508.02.801\nMW 1801, Ernst-Schmidt-Hörsaal (5508.02.801)\nEinführung in die Rechnerarchitektur (IN0004) VO, Standardgruppe\nfix; Abhaltung;" 168 | if desc != expectedDescription { 169 | t.Errorf("Description should be \n\n%s\n\nbut is\n\n%s\n\n", expectedDescription, desc) 170 | return 171 | } 172 | } 173 | 174 | func TestCourseFiltering(t *testing.T) { 175 | testData, app := getTestData(t, "coursefiltering.ics") 176 | 177 | // make sure the unfiltered calendar has 2 entries 178 | fullCalendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{}) 179 | if err != nil { 180 | t.Error(err) 181 | return 182 | } 183 | if len(fullCalendar.Components) != 2 { 184 | t.Errorf("Calendar should have 2 entries before course filtering but has %d", len(fullCalendar.Components)) 185 | return 186 | } 187 | 188 | // now filter out one course 189 | filter := "Einführung in die Rechnerarchitektur (IN0004) VO, Standardgruppe" 190 | filteredCalendar, err := app.getCleanedCalendar([]byte(testData), map[string]bool{filter: true}) 191 | if err != nil { 192 | t.Error(err) 193 | return 194 | } 195 | if len(filteredCalendar.Components) != 1 { 196 | t.Errorf("Calendar should have only 1 entry after course filtering but has %d", len(filteredCalendar.Components)) 197 | return 198 | } 199 | 200 | // make sure the summary does not contain the filtered course's name 201 | summary := filteredCalendar.Components[0].(*ics.VEvent).GetProperty(ics.ComponentPropertySummary).Value 202 | if strings.Contains(summary, filter) { 203 | t.Errorf("Summary should not contain %s but is %s", filter, summary) 204 | return 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/arran4/golang-ical v0.3.2 h1:MGNjcXJFSuCXmYX/RpZhR2HDCYoFuK8vTPFLEdFC3JY= 2 | github.com/arran4/golang-ical v0.3.2/go.mod h1:xblDGxxIUMWwFZk9dlECUlc1iXNV65LJZOTHLVwu8bo= 3 | github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= 4 | github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= 5 | github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 6 | github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 7 | github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= 8 | github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 13 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 14 | github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo= 15 | github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= 16 | github.com/getsentry/sentry-go/gin v0.40.0 h1:kMezKwVF/qdnqp+f5FPM6vIbQeAW13/1Ay/ohP301i8= 17 | github.com/getsentry/sentry-go/gin v0.40.0/go.mod h1:CKVBUdzTvDDbywpGQgT2c1ZsAYOc7pULmsDME/Z3O84= 18 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 19 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 20 | github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= 21 | github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= 22 | github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= 23 | github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 24 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 25 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 26 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 27 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 28 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 29 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 30 | github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= 31 | github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 32 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 33 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 34 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 35 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 36 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 37 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 38 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 39 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 40 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 41 | github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 42 | github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 43 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 44 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 45 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 46 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 50 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 51 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 52 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 53 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 54 | github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= 55 | github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= 56 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 57 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 61 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 62 | github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= 63 | github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 66 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 67 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 68 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 70 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 71 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 72 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 73 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 74 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 75 | github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= 76 | github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 77 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 78 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 79 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 80 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 81 | golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= 82 | golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 83 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 84 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 85 | golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 86 | golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 87 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 88 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 89 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 90 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 91 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 93 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 94 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 95 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 96 | golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 97 | golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 98 | google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= 99 | google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 100 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 101 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 102 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 103 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 104 | -------------------------------------------------------------------------------- /internal/app.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "regexp" 13 | "sort" 14 | "strings" 15 | 16 | ics "github.com/arran4/golang-ical" 17 | "github.com/getsentry/sentry-go" 18 | sentrygin "github.com/getsentry/sentry-go/gin" 19 | "github.com/gin-gonic/gin" 20 | ) 21 | 22 | //go:embed courses.json 23 | var coursesJson string 24 | 25 | //go:embed buildings.json 26 | var buildingsJson string 27 | 28 | //go:embed static 29 | var static embed.FS 30 | 31 | // Version is injected at build time by the compiler with the correct git-commit-sha or "dev" in development 32 | var Version = "dev" 33 | 34 | type App struct { 35 | engine *gin.Engine 36 | 37 | courseReplacements []*Replacement 38 | buildingReplacements map[string]string 39 | } 40 | 41 | type Replacement struct { 42 | key string 43 | value string 44 | } 45 | 46 | type Course struct { 47 | Summary string `json:"summary"` 48 | Hide bool `json:"hide"` 49 | } 50 | 51 | // for sorting replacements by length, then alphabetically 52 | func (r1 *Replacement) isLessThan(r2 *Replacement) bool { 53 | if len(r1.key) != len(r2.key) { 54 | return len(r1.key) > len(r2.key) 55 | } 56 | if r1.key != r2.key { 57 | return r1.key < r2.key 58 | } 59 | return r1.value < r2.value 60 | } 61 | 62 | func newApp() (*App, error) { 63 | a := App{} 64 | 65 | // courseReplacements is a map of course names to shortened names. 66 | // We sort it by length, then alphabetically to ensure a consistent execution order 67 | var rawCourseReplacements map[string]string 68 | if err := json.Unmarshal([]byte(coursesJson), &rawCourseReplacements); err != nil { 69 | return nil, err 70 | } 71 | for key, value := range rawCourseReplacements { 72 | a.courseReplacements = append(a.courseReplacements, &Replacement{key, value}) 73 | } 74 | sort.Slice(a.courseReplacements, func(i, j int) bool { return a.courseReplacements[i].isLessThan(a.courseReplacements[j]) }) 75 | // buildingReplacements is a map of room numbers to building names 76 | if err := json.Unmarshal([]byte(buildingsJson), &a.buildingReplacements); err != nil { 77 | return nil, err 78 | } 79 | return &a, nil 80 | } 81 | 82 | func customLogFormatter(params gin.LogFormatterParams) string { 83 | return fmt.Sprintf("[GIN] %v |%s %3d %s | %13v | %15s |%s %-7s%s %#v\n%s", 84 | params.TimeStamp.Format("2006/01/02 - 15:04:05"), 85 | params.StatusCodeColor(), 86 | params.StatusCode, 87 | params.ResetColor(), 88 | params.Latency, 89 | params.ClientIP, 90 | params.MethodColor(), 91 | params.Method, 92 | params.ResetColor(), 93 | hideTokens(params.Path), 94 | params.ErrorMessage, 95 | ) 96 | } 97 | 98 | func hideTokens(path string) string { 99 | u, err := url.Parse(path) 100 | if err != nil { 101 | return path 102 | } 103 | 104 | pStud := u.Query().Get("pStud") 105 | pPers := u.Query().Get("pPers") 106 | pToken := u.Query().Get("pToken") 107 | 108 | if pToken == "" || (pStud == "" && pPers == "") { 109 | return path 110 | } 111 | 112 | manyXes := strings.Repeat("X", 12) 113 | tokenReplaced := pToken[:4] + manyXes 114 | if pStud != "" { 115 | return fmt.Sprintf("/?pStud=%s&pToken=%s", pStud[:4]+manyXes, tokenReplaced) 116 | } 117 | return fmt.Sprintf("/?pPers=%s&pToken=%s", pPers[:4]+manyXes, tokenReplaced) 118 | } 119 | 120 | func (a *App) Run() error { 121 | if err := sentry.Init(sentry.ClientOptions{ 122 | Dsn: "https://2fbc80ad1a99406cb72601d6a47240ce@glitch.exgen.io/4", 123 | Release: Version, 124 | AttachStacktrace: true, 125 | EnableTracing: true, 126 | // Specify a fixed sample rate: 10% will do for now 127 | TracesSampleRate: 0.1, 128 | }); err != nil { 129 | fmt.Printf("Sentry initialization failed: %v\n", err) 130 | } 131 | 132 | // Create app struct 133 | a, err := newApp() 134 | if err != nil { 135 | return err 136 | } 137 | 138 | // Setup Gin with sentry traces, logger and routes 139 | gin.SetMode("release") 140 | a.engine = gin.New() 141 | a.engine.Use(sentrygin.New(sentrygin.Options{})) 142 | logger := gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/health"}, Formatter: customLogFormatter}) 143 | a.engine.Use(logger, gin.Recovery()) 144 | a.configRoutes() 145 | 146 | // Start the engines 147 | return a.engine.Run(":4321") 148 | } 149 | 150 | func (a *App) configRoutes() { 151 | a.engine.GET("/api/courses", a.handleGetCourses) 152 | a.engine.GET("/health", func(c *gin.Context) { 153 | c.JSON(http.StatusOK, gin.H{ 154 | "status": "ok", 155 | }) 156 | }) 157 | a.engine.Any("/", a.handleIcal) 158 | f := http.FS(static) 159 | a.engine.StaticFS("/files/", f) 160 | a.engine.NoMethod(func(c *gin.Context) { 161 | c.AbortWithStatus(http.StatusNotImplemented) 162 | }) 163 | } 164 | 165 | func getUrl(c *gin.Context) string { 166 | stud := c.Query("pStud") 167 | pers := c.Query("pPers") 168 | token := c.Query("pToken") 169 | if (stud == "" && pers == "") || token == "" { 170 | // Missing parameters: just serve our landing page 171 | f, err := static.Open("static/index.html") 172 | if err != nil { 173 | sentry.CaptureException(err) 174 | c.AbortWithStatusJSON(http.StatusInternalServerError, err) 175 | return "" 176 | } 177 | 178 | if _, err := io.Copy(c.Writer, f); err != nil { 179 | sentry.CaptureException(err) 180 | c.AbortWithStatusJSON(http.StatusInternalServerError, err) 181 | return "" 182 | } 183 | return "" 184 | } 185 | if stud == "" { 186 | return fmt.Sprintf("https://campus.tum.de/tumonlinej/ws/termin/ical?pPers=%s&pToken=%s", pers, token) 187 | } 188 | return fmt.Sprintf("https://campus.tum.de/tumonlinej/ws/termin/ical?pStud=%s&pToken=%s", stud, token) 189 | } 190 | 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") 195 | } 196 | resp, err := http.Get(fetchURL) 197 | if err != nil { 198 | return nil, nil, fmt.Errorf("can't fetch calendar: %w", err) 199 | } 200 | all, err := io.ReadAll(resp.Body) 201 | if err != nil { 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) 220 | return 221 | } 222 | 223 | cleaned, err := a.getCleanedCalendar(allEvents, hiddenCourses) 224 | if err != nil { 225 | ctx.AbortWithStatus(http.StatusInternalServerError) 226 | return 227 | } 228 | 229 | response := []byte(cleaned.Serialize()) 230 | ctx.Header("Content-Type", "text/calendar") 231 | ctx.Header("Content-Length", fmt.Sprintf("%d", len(response))) 232 | 233 | if _, err := ctx.Writer.Write(response); err != nil { 234 | sentry.CaptureException(err) 235 | } 236 | } 237 | 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) { 276 | cal, err := ics.ParseCalendar(strings.NewReader(string(all))) 277 | if err != nil { 278 | return nil, err 279 | } 280 | 281 | // First pass: collect all locations for each dedup key (lecture name + datetime) 282 | // This allows us to show additional rooms in the description when events are deduplicated 283 | eventLocations := make(map[string][]string) 284 | for _, component := range cal.Components { 285 | switch component.(type) { 286 | case *ics.VEvent: 287 | event := component.(*ics.VEvent) 288 | eventSummary := event.GetProperty(ics.ComponentPropertySummary).Value 289 | if hiddenCourses[eventSummary] { 290 | continue 291 | } 292 | dedupKey := fmt.Sprintf("%s-%s", eventSummary, event.GetProperty(ics.ComponentPropertyDtStart)) 293 | if l := event.GetProperty(ics.ComponentPropertyLocation); l != nil && l.Value != "" { 294 | eventLocations[dedupKey] = append(eventLocations[dedupKey], l.Value) 295 | } 296 | } 297 | } 298 | 299 | // Second pass: deduplicate and clean events, adding additional rooms to the description 300 | hasLecture := make(map[string]bool) 301 | var newComponents []ics.Component // saves the components we keep because they are not duplicated 302 | 303 | for _, component := range cal.Components { 304 | switch component.(type) { 305 | case *ics.VEvent: 306 | event := component.(*ics.VEvent) 307 | 308 | // check if the summary contains any of the hidden keys, and if yes, skip it 309 | eventSummary := event.GetProperty(ics.ComponentPropertySummary).Value 310 | if hiddenCourses[eventSummary] { 311 | continue 312 | } 313 | 314 | // deduplicate lectures by their summary and datetime 315 | dedupKey := fmt.Sprintf("%s-%s", event.GetProperty(ics.ComponentPropertySummary).Value, event.GetProperty(ics.ComponentPropertyDtStart)) 316 | if _, ok := hasLecture[dedupKey]; ok { 317 | continue 318 | } 319 | hasLecture[dedupKey] = true // mark event as seen 320 | 321 | // Get additional locations from duplicated events (skip the current event's location and duplicates) 322 | var additionalLocations []string 323 | if locations := eventLocations[dedupKey]; len(locations) > 1 { 324 | currentLocation := "" 325 | if l := event.GetProperty(ics.ComponentPropertyLocation); l != nil { 326 | currentLocation = l.Value 327 | } 328 | seen := make(map[string]bool) 329 | seen[currentLocation] = true 330 | for _, loc := range locations { 331 | if !seen[loc] { 332 | seen[loc] = true 333 | additionalLocations = append(additionalLocations, loc) 334 | } 335 | } 336 | } 337 | 338 | // clean up the event (with additional locations for the description) 339 | a.cleanEvent(event, additionalLocations) 340 | newComponents = append(newComponents, event) 341 | default: // keep everything that is not an event (metadata etc.) 342 | newComponents = append(newComponents, component) 343 | } 344 | } 345 | cal.Components = newComponents 346 | return cal, nil 347 | } 348 | 349 | // matches tags like (IN0001) or [MA2012] and everything after. 350 | // unfortunate also matches wrong brackets like [MA123) but hey… 351 | var reTag = regexp.MustCompile(" ?[\\[(](ED|MW|SOM|CIT|MA|IN|WI|WIB)[0-9]+((_|-|,)[a-zA-Z0-9]+)*[\\])].*") 352 | 353 | // Matches location and teacher from language course title 354 | var reLoc = regexp.MustCompile(" ?(München|Garching|Weihenstephan).+") 355 | 356 | // Matches repeated whitespaces 357 | var reSpace = regexp.MustCompile(`\s\s+`) 358 | 359 | // Matches unique starting numbers like "0000002467 " in "0000002467 Semantik" 360 | var reWeirdStartingNumbers = regexp.MustCompile(`^0\d+ `) 361 | 362 | var unneeded = []string{ 363 | "Standardgruppe", 364 | "PR", 365 | "VO", 366 | "FA", 367 | "VI", 368 | "TT", 369 | "UE", 370 | "SE", 371 | "(Limited places)", 372 | "(Online)", 373 | } 374 | 375 | var reRoom = regexp.MustCompile("^(.*?),.*?(\\d{4})\\.(?:\\d\\d|EG|UG|DG|Z\\d|U\\d)\\.\\d+") 376 | 377 | // matches strings like: (5612.03.017), (5612.EG.017), (5612.EG.010B) 378 | var reNavigaTUM = regexp.MustCompile("\\(\\d{4}\\.[a-zA-Z0-9]{2}\\.\\d{3}[A-Z]?\\)") 379 | 380 | func (a *App) cleanEvent(event *ics.VEvent, additionalLocations []string) { 381 | // Event Title 382 | summary := "" 383 | if s := event.GetProperty(ics.ComponentPropertySummary); s != nil { 384 | summary = cleanEventSummary(s.Value) 385 | } 386 | originalSummary := summary 387 | 388 | // Remove the TAG and anything after e.g.: (IN0001) or [MA0001] 389 | summary = reTag.ReplaceAllString(summary, "") 390 | // remove location and teacher from the language course title 391 | summary = reLoc.ReplaceAllString(summary, "") 392 | summary = reSpace.ReplaceAllString(summary, "") 393 | for _, replace := range unneeded { 394 | summary = strings.ReplaceAll(summary, replace, "") 395 | } 396 | // sometimes the summary has weird numbers attached like "0000002467 " in "0000002467 Semantik" 397 | // What the heck? And why only sometimes??? 398 | summary = reWeirdStartingNumbers.ReplaceAllString(summary, "") 399 | 400 | // Do all the course-specific replacements 401 | for _, repl := range a.courseReplacements { 402 | summary = strings.ReplaceAll(summary, repl.key, repl.value) 403 | } 404 | event.SetSummary(summary) 405 | 406 | // Description 407 | // Remember the old title in the description 408 | description := "" 409 | if d := event.GetProperty(ics.ComponentPropertyDescription); d != nil { 410 | description = d.Value 411 | } 412 | description = originalSummary + "\n" + description 413 | 414 | // Location 415 | // Replace the location with the building name, if it matches our map 416 | location := "" 417 | if l := event.GetProperty(ics.ComponentPropertyLocation); l != nil { 418 | location = event.GetProperty(ics.ComponentPropertyLocation).Value 419 | } 420 | results := reRoom.FindStringSubmatch(location) 421 | if len(results) == 3 { 422 | if building, ok := a.buildingReplacements[results[2]]; ok { 423 | description = location + "\n" + description 424 | event.SetLocation(building) 425 | } 426 | if roomIDs := reNavigaTUM.FindAllString(location, -1); len(roomIDs) > 0 { 427 | for _, roomID := range roomIDs { 428 | roomID = strings.Trim(roomID, "()") 429 | description = fmt.Sprintf("https://nav.tum.de/room/%s\n%s", roomID, description) 430 | } 431 | } 432 | } 433 | 434 | // Add additional locations from deduplicated events to the description 435 | if len(additionalLocations) > 0 { 436 | description = "Additional rooms:\n" + strings.Join(additionalLocations, "\n") + "\n\n" + description 437 | } 438 | event.SetDescription(description) 439 | 440 | // Set status based on ical status, so cancelled events are marked as such in the calendar 441 | switch event.GetProperty(ics.ComponentPropertyStatus).Value { 442 | case "CONFIRMED": 443 | event.SetStatus(ics.ObjectStatusConfirmed) 444 | case "CANCELLED": 445 | event.SetStatus(ics.ObjectStatusCancelled) 446 | case "TENTATIVE": 447 | event.SetStatus(ics.ObjectStatusTentative) 448 | } 449 | } 450 | 451 | func cleanEventSummary(eventSummary string) string { 452 | eventSummary = strings.TrimSpace(eventSummary) 453 | eventSummary = strings.TrimSuffix(eventSummary, " ,") 454 | return eventSummary 455 | } 456 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /internal/buildings.json: -------------------------------------------------------------------------------- 1 | { 2 | "0101": "Theresienstr. 90, 80333 M\u00fcnchen", 3 | "0102": "Theresienstr. 90, 80333 M\u00fcnchen", 4 | "0103": "Theresienstr. 90, 80333 M\u00fcnchen", 5 | "0104": "Theresienstr. 90, 80333 M\u00fcnchen", 6 | "0105": "Theresienstr. 90, 80333 M\u00fcnchen", 7 | "0106": "Theresienstr. 90, 80333 M\u00fcnchen", 8 | "0108": "Theresienstr. 90, 80333 M\u00fcnchen", 9 | "0109": "Theresienstr. 90, 80333 M\u00fcnchen", 10 | "0201": "Gabelsbergerstr. 43, 80333 M\u00fcnchen", 11 | "0202": "Gabelsbergerstr. 39, 80333 M\u00fcnchen", 12 | "0203": "Gabelsbergerstr. 45, 80333 M\u00fcnchen", 13 | "0204": "Gabelsbergerstr. 49, 80333 M\u00fcnchen", 14 | "0205": "Arcisstr. 19, 80333 M\u00fcnchen", 15 | "0206": "Arcisstr. 17, 80333 M\u00fcnchen", 16 | "0305": "Barerstr. 21, 80333 M\u00fcnchen", 17 | "0401": "Richard-Wagner-Str. 18, 80333 M\u00fcnchen", 18 | "0403": "Richard-Wagner-Str. 14, 80333 M\u00fcnchen", 19 | "0501": "Arcisstr. 21, 80333 M\u00fcnchen", 20 | "0502": "Arcisstr. 21, 80333 M\u00fcnchen", 21 | "0503": "Arcisstr. 21, 80333 M\u00fcnchen", 22 | "0504": "Arcisstr. 21, 80333 M\u00fcnchen", 23 | "0505": "Arcisstr. 21, 80333 M\u00fcnchen", 24 | "0506": "Arcisstr. 21, 80333 M\u00fcnchen", 25 | "0507": "Arcisstr. 21, 80333 M\u00fcnchen", 26 | "0508": "Arcisstr. 21, 80333 M\u00fcnchen", 27 | "0509": "Arcisstr. 21, 80333 M\u00fcnchen", 28 | "0510": "Arcisstr. 21, 80333 M\u00fcnchen", 29 | "0511": "Arcisstr. 21, 80333 M\u00fcnchen", 30 | "0512": "Arcisstr. 21, 80333 M\u00fcnchen", 31 | "1501": "Ismaninger Stra\u00dfe 22, 81675 M\u00fcnchen", 32 | "1503": "Ismaningerstr. 22, 81675 M\u00fcnchen", 33 | "1514": "Trogerstr. 9, 81675 M\u00fcnchen", 34 | "1523": "Ismaningerstr. 22, 81675 M\u00fcnchen", 35 | "1531": "Trogerstr.4/Einsteinstr.65 4/65, 81675 M\u00fcnchen", 36 | "1533": "Trogerstr. 8, 81675 M\u00fcnchen", 37 | "1535": "Trogerstr. 12, 81675 M\u00fcnchen", 38 | "1536": "Trogerstr. 14, 81675 M\u00fcnchen", 39 | "1538": "Trogerstr. 18, 81675 M\u00fcnchen", 40 | "1545": "Ismaningerstr. 22, 81675 M\u00fcnchen", 41 | "1548": "Schneckenburgerstr. 8, 81675 M\u00fcnchen", 42 | "1551": "Ismaninger Stra\u00dfe 22, 81675 M\u00fcnchen", 43 | "1559": "Trogerstr. 30, 81675 M\u00fcnchen", 44 | "1601": "Biedersteiner Str. 29, 80802 M\u00fcnchen", 45 | "1602": "Biedersteiner Str. 29, 80802 M\u00fcnchen", 46 | "1603": "Biedersteiner Str. 29, 80802 M\u00fcnchen", 47 | "1607": "Dietlindenstr./Biedersteiner Str. 29, 80802 M\u00fcnchen", 48 | "1608": "Dietlindenstr./Biedersteiner Str. 29, 80802 M\u00fcnchen", 49 | "1650": "Dietlindenstr./Biedersteiner Str. 29, 80802 M\u00fcnchen", 50 | "1652": "Dietlindenstr./Biedersteiner Str. 29, 80802 M\u00fcnchen", 51 | "1713": "Nigerstr. 3, 81675 M\u00fcnchen", 52 | "1725": "Orleanstr. 47, 81667 M\u00fcnchen", 53 | "2103": "Schragenhofstr. 31, 80992 M\u00fcnchen", 54 | "2104": "Schragenhofstr. 31, 80992 M\u00fcnchen", 55 | "2105": "Schragenhofstr. 31, 80992 M\u00fcnchen", 56 | "2106": "Schragenhofstr. 31, 80992 M\u00fcnchen", 57 | "2107": "Schragenhofstr. 31, 80992 M\u00fcnchen", 58 | "2108": "Schragenhofstr. 31, 80992 M\u00fcnchen", 59 | "2109": "Schragenhofstr. 31, 80992 M\u00fcnchen", 60 | "2301": "Connollystr. 32, 80809 M\u00fcnchen", 61 | "2302": "Connollystr. 32, 80809 M\u00fcnchen", 62 | "2303": "Connollystr. 32, 80809 M\u00fcnchen", 63 | "2304": "Connollystr. 32, 80809 M\u00fcnchen", 64 | "2305": "Connollystr. 32, 80809 M\u00fcnchen", 65 | "2308": "Connollystr. 32, 80809 M\u00fcnchen", 66 | "2309": "Connollystr. 32, 80809 M\u00fcnchen", 67 | "2310": "Connollystr. 32, 80809 M\u00fcnchen", 68 | "2311": "Connollystr. 32, 80809 M\u00fcnchen", 69 | "2312": "Connollystr. 32, 80809 M\u00fcnchen", 70 | "2313": "Connollystr. 32, 80809 M\u00fcnchen", 71 | "2315": "Connollystr. 32, 80809 M\u00fcnchen", 72 | "2321": "Connollystr. 32, 80809 M\u00fcnchen", 73 | "2350": "Spiridon-Louis-Ring, 80809 M\u00fcnchen", 74 | "2351": "Olympiapark M\u00fcnchen, 80809 M\u00fcnchen", 75 | "2352": "Olympiapark M\u00fcnchen, 80809 M\u00fcnchen", 76 | "2353": "Staudingerstr., 81737 M\u00fcnchen", 77 | "2354": "Regattaanlage Oberschlei\u00dfheim, 85764 Oberschlei\u00dfheim", 78 | "2361": "Connollystra\u00dfe 32, 80809 M\u00fcnchen", 79 | "2401": "Winzererstr. 45, 80797 M\u00fcnchen", 80 | "2402": "Winzererstr. 45, 80797 M\u00fcnchen", 81 | "2410": "He\u00dfstr. 134, 80797 M\u00fcnchen", 82 | "2522": "Ismanninger Str. 22, 81675 M\u00fcnchen", 83 | "2601": "Baumbachstr. 7, 81245 M\u00fcnchen", 84 | "2602": "Baumbachstr. 7, 81245 M\u00fcnchen", 85 | "2604": "Baumbachstr. 7, 81245 M\u00fcnchen", 86 | "2605": "Baumbachstr. 7, 81245 M\u00fcnchen", 87 | "2607": "Baumbachstr. 7, 81245 M\u00fcnchen", 88 | "2701": "Marchioninistr. 17, 81377 M\u00fcnchen", 89 | "2801": "Marchioninistr. 17, 81377 M\u00fcnchen", 90 | "2804": "Museumsinsel 1, 80538 M\u00fcnchen", 91 | "2805": "Karl-Benz-Str. 15, 85221 Dachau", 92 | "2806": "Oettingenstra\u00dfe 15, 80538 M\u00fcnchen", 93 | "2807": "Barerstr. 40, 80799 M\u00fcnchen", 94 | "2808": "Dr. Albert-Frank-Str. 32, 83308 Trostberg", 95 | "2809": "Praterinsel 2, 80538 M\u00fcnchen", 96 | "2903": "Augustenstr. 44_46, 80333 M\u00fcnchen", 97 | "2906": "Karlstra\u00dfe 45-47, 80333 M\u00fcnchen", 98 | "2907": "Marsstr. 20-22, 80335 M\u00fcnchen", 99 | "2908": "Marsstra\u00dfe 40, 80335 M\u00fcnchen", 100 | "2909": "Denisstr. 1B, 80333 M\u00fcnchen", 101 | "2910": "Richard-Wagner-Str. 1, 80333 M\u00fcnchen", 102 | "2911": "Richard-Wagner-Str. 3, 80333 M\u00fcnchen", 103 | "2926": "Leopoldstr. 139/145, 80804 M\u00fcnchen", 104 | "2927": "Petersgasse 18, 94315 Straubing", 105 | "2929": "Schulgasse 20, 94315 Straubing", 106 | "2940": "Georg-Brauchle-Ring 50_66, 80992 M\u00fcnchen", 107 | "2941": "Georg-Brauchle-Ring 60_62, 80992 M\u00fcnchen", 108 | "3035": "Ingolst\u00e4dter Landstra\u00dfe 1, 85764 Oberschlei\u00dfheim", 109 | "3100": "Am Burghof 3, 99947 M\u00fclverstedt", 110 | "3101": "Obernach/Walchensee, 82432 Obernach", 111 | "3102": "Obernach/Walchensee, 82432 Obernach", 112 | "3103": "Obernach/Walchensee, 82432 Obernach", 113 | "3104": "Obernach/Walchensee, 82432 Obernach", 114 | "3105": "Obernach/Walchensee, 82432 Obernach", 115 | "3106": "Obernach/Walchensee, 82432 Obernach", 116 | "3107": "Obernach/Walchensee, 82432 Obernach", 117 | "3108": "Obernach/Walchensee, 82432 Obernach", 118 | "3109": "Obernach/Walchensee, 82432 Obernach", 119 | "3110": "Obernach/Walchensee, 82432 Obernach", 120 | "3111": "Obernach/Walchensee, 82432 Obernach", 121 | "3112": "Obernach/Walchensee, 82432 Obernach", 122 | "3113": "Obernach/Walchensee, 82432 Obernach", 123 | "3114": "Obernach/Walchensee, 82432 Obernach", 124 | "3115": "Obernach/Walchensee, 82432 Obernach", 125 | "3116": "Obernach/Walchensee, 82432 Obernach", 126 | "3117": "Obernach/Walchensee, 82432 Obernach", 127 | "3118": "Obernach/Walchensee, 82432 Obernach", 128 | "3119": "Obernach/Walchensee, 82432 Obernach", 129 | "3120": "Obernach/Walchensee, 82432 Obernach", 130 | "3121": "Obernach/Walchensee, 82432 Obernach", 131 | "3201": "Lindenweg 15, 82223 Eichenau", 132 | "3401": "Alfons-Goppel-Stra\u00dfe 11, 80333 M\u00fcnchen", 133 | "3402": "Alfons-Goppel-Stra\u00dfe 11, 80333 M\u00fcnchen", 134 | "3501": "Schulgasse 16, 94315 Straubing", 135 | "3502": "Schulgasse 22, 94315 Straubing", 136 | "3503": "Petersgasse 5, 94315 Straubing", 137 | "3901": "Unterer Seeweg 5, 82319 Starnberg", 138 | "3902": "Unterer Seeweg 5, 82319 Starnberg", 139 | "3904": "Unterer Seeweg 5, 82319 Starnberg", 140 | "4001": "Oberer Schlangenweg, 85354 Freising", 141 | "4101": "Alte Akademie 1, 85354 Freising", 142 | "4102": "Alte Akademie 8, 85354 Freising", 143 | "4103": "Alte Akademie 8a, 85354 Freising", 144 | "4105": "Alte Akademie 12, 85354 Freising", 145 | "4106": "Alte Akademie 14, 85354 Freising", 146 | "4107": "Alte Akademie 16, 85354 Freising", 147 | "4108": "Alte Akademie 10, 85354 Freising", 148 | "4109": "Weihenstephaner Steig 22, 85354 Freising", 149 | "4110": "Weihenstephaner Steig 20, 85354 Freising", 150 | "4111": "Weihenstephaner Steig 18, 85354 Freising", 151 | "4113": "Weihenstephaner Steig 16, 85354 Freising", 152 | "4114": "Weihenstephaner Steig 14, 85354 Freising", 153 | "4115": "Weihenstephaner Steig 19, 85350 Freising", 154 | "4116": "Weihenstephaner Steig 17, 85354 Freising", 155 | "4117": "Weihenstephaner Steig ?, 85354 Freising", 156 | "4119": "Hohenbachernstr. 15, 85354 Freising", 157 | "4120": "Hohenbachernstr. 17, 85354 Freising", 158 | "4124": "Weihenstephaner Berg 3, 85354 Freising", 159 | "4126": "Weihenstephaner Berg 1, 85354 Freising", 160 | "4128": "Am Hofgarten, 85354 Freising", 161 | "4129": "M\u00fchlenweg 22, 85354 Freising", 162 | "4130": "Weihenstephaner Berg 13, 85354 Freising", 163 | "4131": "M\u00fchlenweg 22, 85354 Freising", 164 | "4132": "M\u00fchlenweg 18, 85354 Freising", 165 | "4153": "Weihenstephaner Berg 13, 85354 Freising", 166 | "4155": "Weihenstephaner Berg, 85354 Freising", 167 | "4156": "Weihenstephaner Berg 21, 85354 Freising", 168 | "4180": "Staatsgut Veitshof, 85354 Freising", 169 | "4181": "Staatsgut Veitshof, 85354 Freising", 170 | "4182": "Staatsgut Veitshof, 85354 Freising", 171 | "4183": "Staatsgut Veitshof, 85354 Freising", 172 | "4184": "Staatsgut Veitshof, 85354 Freising", 173 | "4185": "Staatsgut Veitshof, 85354 Freising", 174 | "4187": "Staatsgut Veitshof, 85354 Freising", 175 | "4188": "Staatsgut Veitshof, 85354 Freising", 176 | "4189": "Staatsgut Veitshof, 85354 Freising", 177 | "4190": "Staatsgut Veitshof, 85354 Freising", 178 | "4191": "Staatsgut Veitshof, 85354 Freising", 179 | "4192": "Staatsgut Veitshof, 85354 Freising", 180 | "4202": "Liesel-Beckmann-Str., 85354 Freising", 181 | "4205": "Hohenbachernstr., 85354 Freising", 182 | "4210": "Am Staudengarten 2, 85354 Freising", 183 | "4211": "V\u00f6ttinger Stra\u00dfe 36, 85354 Freising", 184 | "4212": "Emil-Erlenmeyer-Forum 5, 85354 Freising", 185 | "4213": "Maximus-von-Imhof-Forum 2, 85354 Freising", 186 | "4214": "Maximus-von-Imhof-Forum 6, 85354 Freising", 187 | "4215": "Maximus-von-Imhof-Forum 4, 85354 Freising", 188 | "4216": "Maximus-von-Imhof-Forum 5, 85354 Freising", 189 | "4217": "Emil-Ramann-Str. 2, 85354 Freising", 190 | "4218": "Emil-Ramann-Str. 4, 85354 Freising", 191 | "4219": "Emil-Ramann-Str. 6, 85354 Freising", 192 | "4220": "Maximus-von-Imhof-Forum 1+3, 85354 Freising", 193 | "4221": "Emil-Erlenmeyer-Forum 7, 85354 Freising", 194 | "4223": "Emil-Ramann-Str. 8, 85354 Freising", 195 | "4224": "Gregor-Mendel-Str. 2, 85354 Freising", 196 | "4225": "Lise-Meitner-Str. 34, 85354 Freising", 197 | "4226": "Gregor-Mendel-Str. 4, 85354 Freising", 198 | "4227": "Hans-Carl-von-Carlowitz-Platz 3, 85354 Freising", 199 | "4230": "Staatsgut Weihenstephan, 85354 Freising", 200 | "4231": "D\u00fcrnast I, 85354 Freising", 201 | "4232": "D\u00fcrnast II, 85354 Freising", 202 | "4234": "D\u00fcrnast III, 85354 Freising", 203 | "4235": "D\u00fcrnast IV, 85354 Freising", 204 | "4236": "Staatsgut Weihenstephan, 85354 Freising", 205 | "4237": "Staatsgut Weihenstephan, 85354 Freising", 206 | "4238": "Emil-Erlenmeyer-Forum 2, 85354 Freising", 207 | "4239": "Emil-Erlenmeyer-Forum 3, 85354 Freising", 208 | "4254": "Emil-Erlenmeyer-Forum 6, 85354 Freising", 209 | "4259": "Emil-Erlenmeyer-Forum 8, 85354 Freising", 210 | "4264": "Emil-Erlenmeyer-Forum 8, 85354 Freising", 211 | "4267": "Emil-Erlenmeyer-Forum 6, 85354 Freising", 212 | "4275": "An der M\u00fchle 20, 85354 Freising", 213 | "4277": "Hans-Carl-von-Carlowitz-Platz 2, 85354 Freising", 214 | "4278": "Hans-Carl-von-Carlowitz-Platz 1, 85354 Freising", 215 | "4281": "Am Staudengarten 1, 85354 Freising", 216 | "4299": "Hohenbachernstra\u00dfe 9, 85354 Freising", 217 | "4303": "Lange Point 51, 85354 Freising", 218 | "4304": "Lange Point 51, 85354 Freising", 219 | "4307": "Liesel-Beckmann-Str. 6, 85354 Freising", 220 | "4308": "Liesel-Beckmann-Str. 4, 85354 Freising", 221 | "4309": "Blumenstr. 16, 85354 Freising", 222 | "4310": "Blumenstr. 16, 85354 Freising", 223 | "4311": "Blumenstr. 16, 85354 Freising", 224 | "4314": "Am Staudengarten, 85354 Freising", 225 | "4315": "Lange Point 51, 85354 Freising", 226 | "4317": "Liesel-Beckmann-Str. 1, 85354 Freising", 227 | "4318": "Liesel-Beckmann-Str. 2, 85354 Freising", 228 | "4319": "Liesel-Beckmann-Stra\u00dfe, 85354 Freising", 229 | "4321": "Lange Point 24, 85354 Freising", 230 | "4322": "Lange Point 24, 85354 Freising", 231 | "4323": "Lange Point 24, 85354 Freising", 232 | "4324": "Lange Point 20, 85354 Freising", 233 | "4353": "Lange Point 4, 85354 Freising", 234 | "4355": "Zur Kreutzbreite 4, 85354 Freising", 235 | "4361": "Am Gereuth 5, 85354 Freising", 236 | "4362": "Lange Point 10, 85354 Freising", 237 | "4368": "Am Gereuth 6, 85354 Freising", 238 | "4387": "Wippenhauserstr. 51, 85354 Freising", 239 | "4401": "Hofmark 3, 82393 Iffeldorf", 240 | "4402": "Hofmark 3, 82393 Iffeldorf", 241 | "4403": "Hofmark 3, 82393 Iffeldorf", 242 | "4404": "Hofmark 3, 82393 Iffeldorf", 243 | "4405": "Hofmark 3, 82393 Iffeldorf", 244 | "4501": "Staatsgut Weihenstephan, 85354 Freising", 245 | "4502": "Staatsgut Weihenstephan, 85354 Freising", 246 | "4503": "Staatsgut Weihenstephan, 85354 Freising", 247 | "4505": "Staatsgut Weihenstephan, 85354 Freising", 248 | "4506": "Staatsgut Weihenstephan, 85354 Freising", 249 | "4508": "Staatsgut Weihenstephan, 85354 Freising", 250 | "4509": "Staatsgut Weihenstephan, 85354 Freising", 251 | "4510": "Staatsgut Weihenstephan, 85354 Freising", 252 | "4512": "Staatsgut Weihenstephan, 85354 Freising", 253 | "4513": "Staatsgut Weihenstephan, 85354 Freising", 254 | "4521": "Viehhausen 4, 85402 Kranzberg", 255 | "4522": "Viehhausen 4, 85402 Kranzberg", 256 | "4523": "Viehhausen 4, 85402 Kranzberg", 257 | "4524": "Viehhausen 4, 85402 Kranzberg", 258 | "4601": "Staatsgut Thalhausen, 85402 Kranzberg", 259 | "4602": "Staatsgut Thalhausen, 85402 Kranzberg", 260 | "4603": "Staatsgut Thalhausen, 85402 Kranzberg", 261 | "4604": "Staatsgut Thalhausen, 85402 Kranzberg", 262 | "4605": "Staatsgut Thalhausen, 85402 Kranzberg", 263 | "4606": "Staatsgut Thalhausen, 85402 Kranzberg", 264 | "4607": "Staatsgut Thalhausen, 85402 Kranzberg", 265 | "4608": "Staatsgut Thalhausen, 85402 Kranzberg", 266 | "4609": "Staatsgut Thalhausen, 85402 Kranzberg", 267 | "4610": "Staatsgut Thalhausen, 85402 Kranzberg", 268 | "4611": "Staatsgut Thalhausen, 85402 Kranzberg", 269 | "4612": "Staatsgut Thalhausen, 85402 Kranzberg", 270 | "4613": "Staatsgut Thalhausen, 85402 Kranzberg", 271 | "4614": "Staatsgut Thalhausen, 85402 Kranzberg", 272 | "4615": "Staatsgut Thalhausen, 85402 Kranzberg", 273 | "4616": "Staatsgut Thalhausen, 85402 Kranzberg", 274 | "4620": "Staatsgut Thalhausen, 85402 Kranzberg", 275 | "4801": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 276 | "4802": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 277 | "4803": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 278 | "4804": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 279 | "4805": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 280 | "4806": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 281 | "4807": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 282 | "4808": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 283 | "4809": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 284 | "4810": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 285 | "4811": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 286 | "4812": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 287 | "4813": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 288 | "4814": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 289 | "4815": "Staatsgut Gr\u00fcnschwaige, 85462 Eitting", 290 | "4901": "Staatsgut Roggenstein, 82223 Eichenau", 291 | "4902": "Staatsgut Roggenstein, 82223 Eichenau", 292 | "4903": "Staatsgut Roggenstein, 82223 Eichenau", 293 | "4907": "Staatsgut Roggenstein, 82223 Eichenau", 294 | "4908": "Staatsgut Roggenstein, 82223 Eichenau", 295 | "4909": "Staatsgut Roggenstein, 82223 Eichenau", 296 | "4910": "Staatsgut Roggenstein, 82223 Eichenau", 297 | "4914": "Staatsgut Roggenstein, 82223 Eichenau", 298 | "4915": "Staatsgut Roggenstein, 82223 Eichenau", 299 | "4916": "Staatsgut Roggenstein, 82223 Eichenau", 300 | "4920": "Staatsgut Roggenstein, 82223 Eichenau", 301 | "5101": "James-Franck-Str. 1, 85748 Garching b. M\u00fcnchen", 302 | "5103": "Boltzmannstr. 10, 85748 Garching b. M\u00fcnchen", 303 | "5104": "Boltzmannstr. 16, 85748 Garching b. M\u00fcnchen", 304 | "5105": "Boltzmannstr. 12, 85748 Garching b. M\u00fcnchen", 305 | "5107": "Am Coulombwall 2, 85748 Garching b. M\u00fcnchen", 306 | "5108": "James-Franck-Str. 1, 85748 Garching b. M\u00fcnchen", 307 | "5109": "Am Coulombwall 1, 85748 Garching b. M\u00fcnchen", 308 | "5110": "James-Franck-Str. 1, 85748 Garching b. M\u00fcnchen", 309 | "5111": "Am Coulombwall 3, 85748 Garching b. M\u00fcnchen", 310 | "5112": "Am Coulombwall 4, 85748 Garching b. M\u00fcnchen", 311 | "5115": "Am Coulombwall 4a, 85748 Garching b. M\u00fcnchen", 312 | "5116": "Am Coulombwall 4a, 85748 Garching b. M\u00fcnchen", 313 | "5120": "Am Coulombwall 6, 85748 Garching b. M\u00fcnchen", 314 | "5121": "Am Coulombwall 6, 85748 Garching b. M\u00fcnchen", 315 | "5122": "Am Coulombwall 6, 85748 Garching b. M\u00fcnchen", 316 | "5123": "Am Coulombwall 1, 85748 Garching b. M\u00fcnchen", 317 | "5124": "James-Franck-Strasse 1, 85748 Garching b. M\u00fcnchen", 318 | "5125": "Am Coloumbwall 1a, 85748 Garching b. M\u00fcnchen", 319 | "5126": "Am Coloumbwall 1b, 85748 Garching b. M\u00fcnchen", 320 | "5130": "Boltzmannstra\u00dfe 10a, 85748 Garching b. M\u00fcnchen", 321 | "5131": "Boltzmannstra\u00dfe 10b, 85748 Garching b. M\u00fcnchen", 322 | "5140": "James-Frank-Str. 1, 85748 Garching b. M\u00fcnchen", 323 | "5160": "Am Coulombwall 6, 85748 Garching b. M\u00fcnchen", 324 | "5202": "Lichtenbergstr. 1, 85748 Garching b. M\u00fcnchen", 325 | "5203": "Walther-Mei\u00dfner-Str. 1, 85748 Garching b. M\u00fcnchen", 326 | "5204": "Walther-Mei\u00dfner-Str. 4, 85748 Garching b. M\u00fcnchen", 327 | "5212": "Walther-Mei\u00dfner-Str. 3, 85748 Garching b. M\u00fcnchen", 328 | "5219": "Walther-Mei\u00dfner-Str. 2, 85748 Garching b. M\u00fcnchen", 329 | "5251": "Walther-Mei\u00dfner-Str. 3, 85748 Garching b. M\u00fcnchen", 330 | "5252": "Walther-Mei\u00dfner-Str. 3, 85748 Garching b. M\u00fcnchen", 331 | "5268": "Walther-Mei\u00dfner-Str. 3, 85748 Garching b. M\u00fcnchen", 332 | "5269": "Walther-Mei\u00dfner-Str. 3, 85748 Garching b. M\u00fcnchen", 333 | "5301": "Lichtenbergstra\u00dfe 2a, 85748 Garching b. M\u00fcnchen", 334 | "5302": "Lichtenbergstr. 2, 85748 Garching b. M\u00fcnchen", 335 | "5401": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 336 | "5402": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 337 | "5403": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 338 | "5404": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 339 | "5406": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 340 | "5407": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 341 | "5408": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 342 | "5409": "Lichtenbergstr. 4, 85748 Garching b. M\u00fcnchen", 343 | "5410": "Ernst-Otto-Fischer-Stra\u00dfe 1, 85748 Garching b. M\u00fcnchen", 344 | "5413": "Ernst-Otto-Fischer-Stra\u00dfe 2, 85748 Garching b. M\u00fcnchen", 345 | "5414": "Lichtenbergstr. 4a, 85748 Garching b. M\u00fcnchen", 346 | "5433": "Lichtenbergstr. 6, 85748 Garching b. M\u00fcnchen", 347 | "5501": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 348 | "5502": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 349 | "5503": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 350 | "5504": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 351 | "5505": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 352 | "5506": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 353 | "5507": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 354 | "5508": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 355 | "5510": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 356 | "5513": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 357 | "5514": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 358 | "5515": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 359 | "5517": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 360 | "5518": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 361 | "5519": "Boltzmannstr. 15, 85748 Garching b. M\u00fcnchen", 362 | "5530": "Boltzmannstr. 17, 85748 Garching b. M\u00fcnchen", 363 | "5531": "Lichtenbergstra\u00dfe 9, 85748 Garching b. M\u00fcnchen", 364 | "5601": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 365 | "5602": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 366 | "5603": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 367 | "5604": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 368 | "5605": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 369 | "5606": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 370 | "5607": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 371 | "5608": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 372 | "5609": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 373 | "5610": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 374 | "5611": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 375 | "5612": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 376 | "5613": "Boltzmannstr. 3, 85748 Garching b. M\u00fcnchen", 377 | "5620": "Boltzmannstr. 5, 85748 Garching b. M\u00fcnchen", 378 | "5622": "Boltzmannstr. 5, 85748 Garching b. M\u00fcnchen", 379 | "5701": "Boltzmannstr. 11, 85748 Garching b. M\u00fcnchen", 380 | "6101": "R\u00f6merhofweg 67, 85748 Garching b. M\u00fcnchen", 381 | "6102": "R\u00f6merhofweg 67, 85748 Garching b. M\u00fcnchen", 382 | "6103": "R\u00f6merhofweg 67, 85748 Garching b. M\u00fcnchen", 383 | "6107": "R\u00f6merhofweg 67, 85748 Garching b. M\u00fcnchen", 384 | "6202": "R\u00f6merhofweg 67, 85748 Garching b. M\u00fcnchen", 385 | "7910": "Ludwig-Prandtl-Stra\u00dfe 1, 85748 Garching b. M\u00fcnchen", 386 | "8101": "Parkring 11-13, 85748 Garching b. M\u00fcnchen", 387 | "8102": "Parkring 35-39, 85748 Garching b. M\u00fcnchen", 388 | "8111": "Schlei\u00dfheimerstra\u00dfe 90a, 85748 Garching b. M\u00fcnchen", 389 | "8120": "Walther-von-Dyck-Strasse 10, 85748 Garching b. M\u00fcnchen", 390 | "8121": "Walther-von-Dyck-Strasse 12, 85748 Garching b. M\u00fcnchen", 391 | "9001": "Raitenhaslach 11, 84489 Burghausen", 392 | "9377": "Lise-Meitner-Stra\u00dfe 9-11, 85521 Ottobrunn" 393 | } 394 | --------------------------------------------------------------------------------