Merge remote-tracking branch 'htwk-org/development'

# Conflicts:
#	backend/go.mod
#	frontend/index.html
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/public/themes/lara-dark-blue/theme.css
#	frontend/public/themes/lara-dark-blue/theme.css.map
#	frontend/public/themes/lara-light-blue/theme.css
#	frontend/public/themes/lara-light-blue/theme.css.map
#	frontend/src/App.vue
#	frontend/src/components/DarkModeSwitcher.vue
#	frontend/src/i18n/index.ts
#	frontend/src/main.ts
#	frontend/src/router/index.ts
#	frontend/src/view/CalendarLink.vue
#	frontend/src/view/edit/EditCalendar.vue
#	frontend/vite.config.ts
#	reverseproxy.conf
#	reverseproxy.local.conf
#	services/data-manager/main.go
#	services/data-manager/model/roomOccupancyModel.go
#	services/data-manager/service/addRoute.go
#	services/data-manager/service/addSchedule.go
#	services/data-manager/service/db/dbGroups.go
#	services/data-manager/service/feed/feedFunctions.go
#	services/data-manager/service/fetch/sport/sportFetcher.go
#	services/data-manager/service/fetch/v1/fetchSeminarEventService.go
#	services/data-manager/service/fetch/v1/fetchSeminarGroupService.go
#	services/data-manager/service/fetch/v2/fetcher.go
#	services/data-manager/service/functions/filter.go
#	services/data-manager/service/functions/filter_test.go
#	services/data-manager/service/functions/time/parse.go
#	services/data-manager/service/room/roomService.go
#	services/data-manager/service/room/roomService_test.go
#	services/go.sum
#	services/ical/service/connector/grpc/client.go
This commit is contained in:
Elmar Kresse
2024-07-24 12:16:51 +02:00
188 changed files with 9639 additions and 26900 deletions

View File

@@ -0,0 +1,78 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package service
import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"htwkalender/data-manager/service/feed"
"htwkalender/data-manager/service/ical"
"io"
"log/slog"
"net/http"
)
func addFeedRoutes(app *pocketbase.PocketBase) {
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodPost,
Path: "/api/feed",
Handler: func(c echo.Context) error {
requestBody, _ := io.ReadAll(c.Request().Body)
result, err := ical.CreateIndividualFeed(requestBody, app)
if err != nil {
slog.Error("Failed to create individual feed", "error", err)
return c.JSON(http.StatusInternalServerError, "Failed to create individual feed")
}
return c.JSON(http.StatusOK, result)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
if err != nil {
return err
}
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodDelete,
Path: "/api/feed",
Handler: func(c echo.Context) error {
token := c.QueryParam("token")
err := feed.MarkFeedForDeletion(app.Dao(), token)
if err != nil {
return c.JSON(http.StatusNotFound, err)
} else {
return c.JSON(http.StatusOK, "Feed deleted")
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,467 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package service
import (
"htwkalender/data-manager/model/serviceModel"
"htwkalender/data-manager/service/course"
"htwkalender/data-manager/service/fetch/sport"
v1 "htwkalender/data-manager/service/fetch/v1"
v2 "htwkalender/data-manager/service/fetch/v2"
"htwkalender/data-manager/service/functions/time"
"htwkalender/data-manager/service/room"
"log/slog"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"go.mongodb.org/mongo-driver/bson"
)
const RoomOccupancyGranularity = 15
func AddRoutes(services serviceModel.Service) {
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/fetch/events",
Handler: func(c echo.Context) error {
savedEvents, err := v2.ParseEventsFromRemote(services.App)
if err != nil {
slog.Error("Failed to parse events from remote: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to parse events from remote")
} else {
return c.JSON(http.StatusOK, savedEvents)
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/fetch/daily/events",
Handler: func(c echo.Context) error {
course.UpdateCourse(services)
return c.JSON(http.StatusOK, "Daily events fetched")
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/fetch/groups",
Handler: func(c echo.Context) error {
groups, err := v1.FetchSeminarGroups(services.App)
if err != nil {
return c.JSON(http.StatusBadRequest, "Failed to fetch seminar groups")
}
return c.JSON(http.StatusOK, groups)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/fetch/sports",
Handler: func(c echo.Context) error {
sportEvents, err := sport.FetchAndUpdateSportEvents(services.App)
if err != nil {
return c.JSON(http.StatusBadRequest, "Failed to fetch sport events")
}
return c.JSON(http.StatusOK, sportEvents)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodDelete,
Path: "/api/modules",
Handler: func(c echo.Context) error {
err := services.EventService.DeleteAllEvents()
if err != nil {
return c.JSON(http.StatusBadRequest, "Failed to delete events")
}
return c.JSON(http.StatusOK, "Events deleted")
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/rooms",
Handler: func(c echo.Context) error {
rooms, err := room.GetRooms(services.App)
if err != nil {
return c.JSON(http.StatusBadRequest, "Failed to get rooms")
}
return c.JSON(http.StatusOK, rooms)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
// API Endpoint to get all events for a specific room on a specific day
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/schedule/day",
Handler: func(c echo.Context) error {
roomParam := c.QueryParam("room")
date := c.QueryParam("date")
roomSchedule, err := room.GetRoomScheduleForDay(services.App, roomParam, date)
if err != nil {
slog.Error("Failed to get room schedule for day: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get room schedule for day")
}
return c.JSON(http.StatusOK, roomSchedule)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
// API Endpoint to get room occupancy for a time period for all rooms, when requested as BSON
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/schedule/rooms",
Handler: func(c echo.Context) error {
from := c.QueryParam("from")
to := c.QueryParam("to")
rooms, err := room.GetRoomOccupancyList(app, from, to, RoomOccupancyGranularity)
if err != nil {
slog.Error("Failed to get room occupancy: %v", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get room occupancy")
}
bson_coded, err := bson.Marshal(rooms)
if err != nil {
slog.Error("Failed to encode room occupancy to BSON: %v", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to encode room occupancy to BSON")
}
return c.Blob(http.StatusOK, "application/bson", bson_coded)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
if err != nil {
return err
}
return nil
})
// API Endpoint to create a new iCal feed
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/schedule",
Handler: func(c echo.Context) error {
roomParam := c.QueryParam("room")
to := c.QueryParam("to")
from := c.QueryParam("from")
roomSchedule, err := room.GetRoomSchedule(services.App, roomParam, from, to)
if err != nil {
slog.Error("Failed to get room schedule:", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get room schedule")
}
return c.JSON(http.StatusOK, roomSchedule)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
// API Endpoint to get all rooms that have no events in a specific time frame
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/rooms/free",
Handler: func(c echo.Context) error {
from, err := time.ParseTime(c.QueryParam("from"))
if err != nil {
slog.Error("Failed to parse time: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to parse time")
}
to, err := time.ParseTime(c.QueryParam("to"))
if err != nil {
slog.Error("Failed to parse time: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to parse time")
}
rooms, err := room.GetFreeRooms(services.App, from, to)
if err != nil {
slog.Error("Failed to get free rooms: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get free rooms")
}
return c.JSON(http.StatusOK, rooms)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
addFeedRoutes(services.App)
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/course/modules",
Handler: func(c echo.Context) error {
modules, err := services.EventService.GetModulesForCourseDistinct(
c.QueryParam("course"),
c.QueryParam("semester"),
)
if err != nil {
slog.Error("Failed to get modules for course and semester: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get modules for course and semester")
} else {
return c.JSON(http.StatusOK, modules)
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/modules",
Handler: func(c echo.Context) error {
modules, err := services.EventService.GetAllModulesDistinct()
if err != nil {
slog.Error("Failed to get modules: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get modules")
}
return c.JSON(http.StatusOK, modules)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/module",
Handler: func(c echo.Context) error {
requestModule := c.QueryParam("uuid")
module, err := services.EventService.GetModuleByUUID(requestModule)
if err != nil {
slog.Error("Failed to get module: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get module")
} else {
return c.JSON(http.StatusOK, module)
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/courses",
Handler: func(c echo.Context) error {
semester := c.QueryParam("semester")
if semester == "" {
courses := services.CourseService.GetAllCourses()
return c.JSON(200, courses)
} else {
seminarGroups := services.CourseService.GetAllCoursesForSemester(semester)
courseStringList := make([]string, 0)
for _, seminarGroup := range seminarGroups {
courseStringList = append(courseStringList, seminarGroup.Course)
}
return c.JSON(200, courseStringList)
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
// api end point to get all courses for a specific semester with courses that have events
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/courses/events",
Handler: func(c echo.Context) error {
semester := c.QueryParam("semester")
courses, err := services.CourseService.GetAllCoursesForSemesterWithEvents(semester)
if err != nil {
slog.Error("Failed to get courses for semester with events: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get courses for semester with events")
} else {
return c.JSON(http.StatusOK, courses)
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
// API Endpoint to get all eventTypes from the database distinct
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/events/types",
Handler: func(c echo.Context) error {
eventTypes, err := services.EventService.GetEventTypes()
if err != nil {
slog.Error("Failed to get event types", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to get event types")
} else {
return c.JSON(http.StatusOK, eventTypes)
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
},
})
if err != nil {
return err
}
return nil
})
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodDelete,
Path: "/api/events",
Handler: func(c echo.Context) error {
err := services.EventService.DeleteAllEventsByCourseAndSemester(
c.QueryParam("course"),
c.QueryParam("semester"),
)
if err != nil {
slog.Error("Failed to delete events: ", "error", err)
return c.JSON(http.StatusBadRequest, "Failed to delete events")
} else {
return c.JSON(http.StatusOK, "Events deleted")
}
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(services.App),
apis.RequireAdminAuth(),
},
})
if err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,86 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package service
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/cron"
"htwkalender/data-manager/model/serviceModel"
"htwkalender/data-manager/service/course"
"htwkalender/data-manager/service/feed"
"htwkalender/data-manager/service/fetch/sport"
v1 "htwkalender/data-manager/service/fetch/v1"
v2 "htwkalender/data-manager/service/fetch/v2"
"htwkalender/data-manager/service/functions/time"
"log/slog"
"strconv"
)
func AddSchedules(services serviceModel.Service) {
services.App.OnBeforeServe().Add(func(e *core.ServeEvent) error {
scheduler := cron.New()
// !! IMPORTANT !! CRON is based on UTC time zone so in Germany it is UTC+2 in summer and UTC+1 in winter
// Every sunday at 10pm update all courses (5 segments - minute, hour, day, month, weekday) "0 22 * * 0"
scheduler.MustAdd("updateCourses", "0 22 * * 0", func() {
slog.Info("Started updating courses schedule")
groups, err := v1.FetchSeminarGroups(services.App)
if err != nil {
slog.Warn("Failed to fetch seminar groups: ", "error", err)
}
slog.Info("Successfully fetched " + strconv.FormatInt(int64(len(groups)), 10) + " seminar groups")
})
// Every day at 5am and 5pm update all courses (5 segments - minute, hour, day, month, weekday) "0 5,17 * * *"
// In Germany it is 7am and 7pm, syllabus gets updated twice a day at German 5:00 Uhr and 17:00 Uhr
scheduler.MustAdd("updateEventsByCourse", "0 5,17 * * *", func() {
slog.Info("Started updating courses schedule")
course.UpdateCourse(services)
})
// Every sunday at 1am clean all courses (5 segments - minute, hour, day, month, weekday) "0 3 * * 0"
scheduler.MustAdd("cleanFeeds", "0 1 * * 0", func() {
// clean feeds older than 6 months
slog.Info("Started cleaning feeds schedule")
feed.ClearFeeds(services.App.Dao(), 6, time.RealClock{})
})
// Every sunday at 3am fetch all sport events (5 segments - minute, hour, day, month, weekday) "0 2 * * 0"
scheduler.MustAdd("fetchSportEvents", "0 3 * * 0", func() {
slog.Info("Started fetching sport events schedule")
sportEvents, err := sport.FetchAndUpdateSportEvents(services.App)
if err != nil {
slog.Error("Failed to fetch and save sport events:", "error", err)
}
slog.Info("Successfully fetched " + strconv.FormatInt(int64(len(sportEvents)), 10) + " sport events")
})
//fetch all events for semester and delete from remote this should be done every sunday at 2am
scheduler.MustAdd("fetchEvents", "0 22 * * 6", func() {
savedEvents, err := v2.FetchAllEventsAndSave(services.App, time.RealClock{})
if err != nil {
slog.Error("Failed to fetch and save events: ", "error", err)
} else {
slog.Info("Successfully fetched " + strconv.FormatInt(int64(len(savedEvents)), 10) + " events")
}
})
scheduler.Start()
return nil
})
}

View File

@@ -0,0 +1,42 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package course
import (
"htwkalender/data-manager/model"
"htwkalender/data-manager/model/serviceModel"
"htwkalender/data-manager/service/functions"
"htwkalender/data-manager/service/functions/time"
"log/slog"
)
func UpdateCourse(service serviceModel.Service) {
currentSemesters := functions.CalculateSemesterList(time.RealClock{})
var seminarGroups []model.SeminarGroup
for _, semester := range currentSemesters {
seminarGroups = append(seminarGroups, service.CourseService.GetAllCoursesForSemester(semester)...)
}
for _, seminarGroup := range seminarGroups {
_, err := service.EventService.UpdateModulesForCourse(seminarGroup)
if err != nil {
slog.Warn("Update Course: "+seminarGroup.Course+" failed:", "error", err)
}
}
}

View File

@@ -0,0 +1,97 @@
package course
import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"htwkalender/data-manager/model"
"htwkalender/data-manager/model/serviceModel"
"htwkalender/data-manager/service/events/mock"
"log/slog"
"regexp"
"testing"
)
// CustomWriter is a custom writer to capture log output
type CustomWriter struct {
Buffer bytes.Buffer
}
func (w *CustomWriter) Write(p []byte) (n int, err error) {
return w.Buffer.Write(p)
}
func TestUpdateCourse(t *testing.T) {
// Create mock services
mockCourseService := new(mock.MockCourseService)
mockEventService := new(mock.MockEventService)
events := model.Events{}
// Set up expectations
mockCourseService.On("GetAllCoursesForSemester", "ss").Return([]model.SeminarGroup{{Course: "Course1", Semester: ""}, {Course: "Course2", Semester: ""}})
mockEventService.On("UpdateModulesForCourse", model.SeminarGroup{Course: "Course1", Semester: ""}).Return(events, nil)
mockEventService.On("UpdateModulesForCourse", model.SeminarGroup{Course: "Course2", Semester: ""}).Return(events, nil)
// Inject mocks into the UpdateCourse function
service := serviceModel.Service{
CourseService: mockCourseService,
EventService: mockEventService,
App: nil,
}
UpdateCourse(service)
// Assert that the expectations were met
mockCourseService.AssertExpectations(t)
mockEventService.AssertExpectations(t)
// Assert that the UpdateCourse function was called twice
mockCourseService.AssertNumberOfCalls(t, "GetAllCoursesForSemester", 1)
mockEventService.AssertNumberOfCalls(t, "UpdateModulesForCourse", 2)
// Assert that the UpdateCourse function was called with the correct arguments
mockEventService.AssertCalled(t, "UpdateModulesForCourse", model.SeminarGroup{Course: "Course1", Semester: ""})
mockEventService.AssertCalled(t, "UpdateModulesForCourse", model.SeminarGroup{Course: "Course2", Semester: ""})
}
func TestUpdateCourseErr(t *testing.T) {
// Create mock services
mockCourseService := new(mock.MockCourseService)
mockEventService := new(mock.MockEventService)
events := model.Events{}
// Set up expectations
mockCourseService.On("GetAllCoursesForSemester", "ss").Return([]model.SeminarGroup{{Course: "Course1", Semester: ""}, {Course: "Course2", Semester: ""}})
mockEventService.On("UpdateModulesForCourse", model.SeminarGroup{Course: "Course1", Semester: ""}).Return(events, fmt.Errorf("error"))
mockEventService.On("UpdateModulesForCourse", model.SeminarGroup{Course: "Course2", Semester: ""}).Return(events, fmt.Errorf("error"))
// Create a custom writer to capture log output
customWriter := &CustomWriter{}
originalLogger := slog.Default()
defer slog.SetDefault(originalLogger)
// Replace the default logger with a custom logger
slog.SetDefault(slog.New(slog.NewTextHandler(customWriter, nil)))
// Inject mocks into the UpdateCourse function
service := serviceModel.Service{
CourseService: mockCourseService,
EventService: mockEventService,
App: nil,
}
UpdateCourse(service)
// Assert that the expectations were met
mockCourseService.AssertExpectations(t)
mockEventService.AssertExpectations(t)
// Assert that the UpdateCourse function was called twice
mockCourseService.AssertNumberOfCalls(t, "GetAllCoursesForSemester", 1)
mockEventService.AssertNumberOfCalls(t, "UpdateModulesForCourse", 2)
// Check the captured log output for the expected messages
logOutput := customWriter.Buffer.String()
require.Regexp(t, regexp.MustCompile(`Update Course: Course1 failed:.*error`), logOutput)
require.Regexp(t, regexp.MustCompile(`Update Course: Course2 failed:.*error`), logOutput)
}

View File

@@ -0,0 +1,86 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package date
import (
"log/slog"
"strconv"
"strings"
"time"
_ "time/tzdata"
)
func GetDateFromWeekNumber(year int, weekNumber int, dayName string) (time.Time, error) {
// Create a time.Date for the first day of the year
europeTime, err := time.LoadLocation("Europe/Berlin")
if err != nil {
slog.Error("Failed to load location: ", "error", err)
return time.Time{}, err
}
firstDayOfYear := time.Date(year, time.January, 1, 0, 0, 0, 0, europeTime)
// Calculate the number of days to add to reach the desired week
daysToAdd := time.Duration((weekNumber-1)*7) * 24 * time.Hour
// Find the starting day of the week (e.g., Monday)
startingDayOfWeek := firstDayOfYear
// check if the first day of the year is friday or saturday or sunday
if startingDayOfWeek.Weekday() == time.Friday || startingDayOfWeek.Weekday() == time.Saturday || startingDayOfWeek.Weekday() == time.Sunday {
for startingDayOfWeek.Weekday() != time.Monday {
startingDayOfWeek = startingDayOfWeek.Add(24 * time.Hour)
}
} else {
for startingDayOfWeek.Weekday() != time.Monday {
startingDayOfWeek = startingDayOfWeek.Add(-24 * time.Hour)
}
}
// Calculate the desired date by adding daysToAdd and adjusting for the day name
desiredDate := startingDayOfWeek.Add(daysToAdd)
// Find the day of the week
dayOfWeek := map[string]time.Weekday{
"Montag": time.Monday,
"Dienstag": time.Tuesday,
"Mittwoch": time.Wednesday,
"Donnerstag": time.Thursday,
"Freitag": time.Friday,
"Samstag": time.Saturday,
"Sonntag": time.Sunday,
}[dayName]
// Adjust to the desired day of the week
for desiredDate.Weekday() != dayOfWeek {
desiredDate = desiredDate.Add(24 * time.Hour)
}
return desiredDate, nil
}
// createEventFromTableData should create an event from the table data
// tableTime represents Hour and Minute like HH:MM
// tableDate returns a Time
func CreateTimeFromHourAndMinuteString(tableTime string) time.Time {
timeParts := strings.Split(tableTime, ":")
hour, _ := strconv.Atoi(timeParts[0])
minute, _ := strconv.Atoi(timeParts[1])
return time.Date(0, 0, 0, hour, minute, 0, 0, time.UTC)
}

View File

@@ -0,0 +1,83 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package date
import (
"reflect"
"testing"
"time"
_ "time/tzdata"
)
func TestGetDateFromWeekNumber(t *testing.T) {
europeTime, _ := time.LoadLocation("Europe/Berlin")
type args struct {
year int
weekNumber int
dayName string
}
tests := []struct {
name string
args args
want time.Time
wantErr bool
}{
{
name: "Test 1",
args: args{
year: 2021,
weekNumber: 1,
dayName: "Montag",
},
want: time.Date(2021, 1, 4, 0, 0, 0, 0, europeTime),
wantErr: false,
},
{
name: "Test 2",
args: args{
year: 2023,
weekNumber: 57,
dayName: "Montag",
},
want: time.Date(2024, 1, 29, 0, 0, 0, 0, europeTime),
wantErr: false,
},
{
name: "Test 3",
args: args{
year: 2023,
weekNumber: 1,
dayName: "Montag",
},
want: time.Date(2023, 1, 2, 0, 0, 0, 0, europeTime),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetDateFromWeekNumber(tt.args.year, tt.args.weekNumber, tt.args.dayName)
if (err != nil) != tt.wantErr {
t.Errorf("getDateFromWeekNumber() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("getDateFromWeekNumber() got = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,408 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"fmt"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tools/types"
"htwkalender/data-manager/model"
"log/slog"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
)
func SaveSeminarGroupEvents(seminarGroup model.SeminarGroup, app *pocketbase.PocketBase) ([]model.Event, error) {
var toBeSavedEvents model.Events
var savedRecords model.Events
// check if event is already in database and add to toBeSavedEvents if not
for _, event := range seminarGroup.Events {
event = event.SetCourse(seminarGroup.Course)
existsInDatabase, err := findEventByDayWeekStartEndNameCourse(event, seminarGroup.Course, app.Dao())
alreadyAddedToSave := toBeSavedEvents.Contains(event)
if err != nil {
return nil, err
}
if !existsInDatabase && !alreadyAddedToSave {
toBeSavedEvents = append(toBeSavedEvents, event)
}
}
// create record for each event that's not already in the database
for _, event := range toBeSavedEvents {
event.MarkAsNew()
// auto mapping for event fields to record fields
err := app.Dao().Save(&event)
if err != nil {
return nil, err
} else {
savedRecords = append(savedRecords, event)
}
}
return savedRecords, nil
}
func SaveEvents(events []model.Event, txDao *daos.Dao) ([]model.Event, error) {
var toBeSavedEvents model.Events
var savedRecords model.Events
// check if event is already in database and add to toBeSavedEvents if not
for _, event := range events {
existsInDatabase, err := findEventByDayWeekStartEndNameCourse(event, event.Course, txDao)
alreadyAddedToSave := toBeSavedEvents.Contains(event)
if err != nil {
return nil, err
}
if !existsInDatabase && !alreadyAddedToSave {
toBeSavedEvents = append(toBeSavedEvents, event)
}
}
// create record for each event that's not already in the database
for _, event := range toBeSavedEvents {
event.MarkAsNew()
// auto mapping for event fields to record fields
err := txDao.Save(&event)
if err != nil {
return nil, err
} else {
savedRecords = append(savedRecords, event)
}
}
return savedRecords, nil
}
// check if event is already in database and return true if it is and false if it's not
func findEventByDayWeekStartEndNameCourse(event model.Event, course string, dao *daos.Dao) (bool, error) {
var dbEvent model.Event
err := dao.DB().Select("*").From("events").
Where(dbx.NewExp(
"Day = {:day} AND "+
"Week = {:week} AND "+
"Start = {:start} AND "+
"End = {:end} AND "+
"Name = {:name} AND "+
"course = {:course} AND "+
"Prof = {:prof} AND "+
"Rooms = {:rooms} AND "+
"EventType = {:eventType}",
dbx.Params{
"day": event.Day,
"week": event.Week,
"start": event.Start,
"end": event.End,
"name": event.Name,
"course": course,
"prof": event.Prof,
"rooms": event.Rooms,
"eventType": event.EventType}),
).One(&dbEvent)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return false, nil
}
return false, err
} else {
return true, nil
}
}
func buildIcalQueryForModules(modulesUuid []string) dbx.Expression {
// check uuids against sql injection
// uuids are generated by the system and are not user input
// following the pattern of only containing alphanumeric characters and dashes
for _, moduleUuid := range modulesUuid {
err := uuid.Validate(moduleUuid)
if err != nil {
slog.Warn("Module UUID is not safe: ", "moduleUuid", moduleUuid)
return dbx.HashExp{}
}
}
// build where conditions for each module
//first check if modules is empty
if len(modulesUuid) == 0 {
return dbx.HashExp{}
}
//second check if modules has only one element
if len(modulesUuid) == 1 {
return dbx.HashExp{"uuid": modulesUuid[0]}
}
//third check if modules has more than one element
var wheres []dbx.Expression
for _, moduleUuid := range modulesUuid {
where := dbx.HashExp{"uuid": moduleUuid}
wheres = append(wheres, where)
}
// Use dbx.And or dbx.Or to combine the where conditions as needed
where := dbx.Or(wheres...)
return where
}
// GetPlanForModules returns all events for the given modules with the given course
// used for the ical feed
func GetPlanForModules(app *pocketbase.PocketBase, modules []string) (model.Events, error) {
var events model.Events
// iterate over modules in 100 batch sizes
for i := 0; i < len(modules); i += 100 {
var moduleBatch []string
if i+100 > len(modules) {
moduleBatch = modules[i:]
} else {
moduleBatch = modules[i : i+100]
}
var selectedModulesQuery = buildIcalQueryForModules(moduleBatch)
// get all events from event records in the events collection
err := app.Dao().DB().Select("*").From("events").Where(selectedModulesQuery).OrderBy("start").All(&events)
if err != nil {
return nil, err
}
}
return events, nil
}
func GetAllEventsForCourse(app *pocketbase.PocketBase, course string) (model.Events, error) {
var events model.Events
// get all events from event records in the events collection
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("course = {:course}", dbx.Params{"course": course})).All(&events)
if err != nil {
slog.Error("Error while getting events from database: ", "error", err)
return nil, fmt.Errorf("error while getting events from database for course %s", course)
}
return events, nil
}
func GetAllModulesForCourse(app *pocketbase.PocketBase, course string, semester string) (model.Events, error) {
var events model.Events
// get all events from event records in the events collection
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("course = {:course} AND semester = {:semester}", dbx.Params{"course": course, "semester": semester})).GroupBy("Name").Distinct(true).All(&events)
if err != nil {
slog.Error("Error while getting events from database: ", "error", err)
return nil, fmt.Errorf("error while getting events from database for course %s and semester %s", course, semester)
}
return events, nil
}
func GetAllModulesDistinctByNameAndCourse(app *pocketbase.PocketBase) ([]model.ModuleDTO, error) {
var modules []model.ModuleDTO
err := app.Dao().DB().Select("Name", "EventType", "Prof", "course", "semester", "uuid").From("events").GroupBy("Name", "Course").Distinct(true).All(&modules)
if err != nil {
slog.Error("Error while getting events from database: ", "error", err)
return nil, fmt.Errorf("error while getting events distinct by name and course from data")
}
return modules, nil
}
func DeleteAllEventsByCourse(dao *daos.Dao, course string, semester string) error {
_, err := dao.DB().Delete("events", dbx.NewExp("course = {:course} AND semester = {:semester}", dbx.Params{"course": course, "semester": semester})).Execute()
if err != nil {
return err
}
return nil
}
func DeleteAllEventsRatherThenCourse(dao *daos.Dao, course string, semester string) error {
_, err := dao.DB().Delete("events", dbx.NewExp("course != {:course} AND semester = {:semester}", dbx.Params{"course": course, "semester": semester})).Execute()
if err != nil {
return err
}
return nil
}
func DeleteAllEvents(app *pocketbase.PocketBase) error {
_, err := app.Dao().DB().Delete("events", dbx.NewExp("1=1")).Execute()
if err != nil {
return err
}
return nil
}
func FindModuleByUUID(app *pocketbase.PocketBase, uuid string) (model.Module, error) {
var module model.Module
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("uuid = {:uuid}", dbx.Params{"uuid": uuid})).One(&module)
if err != nil {
return model.Module{}, err
}
return module, nil
}
func FindAllEventsByModule(app *pocketbase.PocketBase, module model.Module) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Name = {:moduleName} AND course = {:course}", dbx.Params{"moduleName": module.Name, "course": module.Course})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetAllModulesByNameAndDateRange(app *pocketbase.PocketBase, name string, startDate time.Time, endDate time.Time) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Name = {:name} AND Start >= {:startDate} AND End <= {:endDate}", dbx.Params{"name": name, "startDate": startDate, "endDate": endDate})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
// GetEventsThatCollideWithTimeRange returns all events that collide with the given time range
// we have events with start and end in the database, we want to get all events that collide with the given time range
// we have 4 cases:
// 1. event starts before the given time range and ends after the given time range
// 2. event starts after the given time range and ends before the given time range
// 3. event starts before the given time range and ends before the given time range
// 4. event starts after the given time range and ends after the given time range
func GetEventsThatCollideWithTimeRange(app *pocketbase.PocketBase, from time.Time, to time.Time) (model.Events, error) {
var fromTypeTime, _ = types.ParseDateTime(from)
var toTypeTime, _ = types.ParseDateTime(to)
events1, err := GetEventsThatStartBeforeAndEndAfter(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
events2, err := GetEventsThatStartAfterAndEndBefore(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
events3, err := GetEventsThatStartBeforeAndEndBefore(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
events4, err := GetEventsThatStartAfterAndEndAfter(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
var events model.Events
events = append(events, events1...)
events = append(events, events2...)
events = append(events, events3...)
events = append(events, events4...)
return events, nil
}
func GetEventsThatStartBeforeAndEndAfter(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start <= {:startDate} AND End >= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).Distinct(true).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetEventsThatStartAfterAndEndBefore(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start >= {:startDate} AND End <= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetEventsThatStartBeforeAndEndBefore(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start <= {:startDate} AND End <= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetAllEventTypes(app *pocketbase.PocketBase) ([]model.EventType, error) {
var eventTypes []model.EventType
err := app.Dao().DB().Select("EventType").From("events").GroupBy("EventType").Distinct(true).All(&eventTypes)
if err != nil {
return nil, err
}
return eventTypes, nil
}
func GetEventsThatStartAfterAndEndAfter(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start >= {:startDate} AND End >= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func DeleteEvents(list model.Events, app *pocketbase.PocketBase) error {
for _, event := range list {
err := app.Dao().Delete(&event)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,57 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"github.com/pocketbase/dbx"
"reflect"
"testing"
)
func TestBuildIcalQueryForModules(t *testing.T) {
type args struct {
modules []string
}
tests := []struct {
name string
args args
want dbx.Expression
}{
{
name: "empty modules",
args: args{modules: []string{}},
want: dbx.HashExp{},
},
{
name: "one module",
args: args{modules: []string{"77eddc32-c49d-5d0a-8c36-17b266396641"}},
want: dbx.HashExp{"uuid": "77eddc32-c49d-5d0a-8c36-17b266396641"},
},
{
name: "two modules",
args: args{modules: []string{"9e5081e6-4c56-57b9-9965-f6dc74559755", "48cd8c4e-fb70-595c-9dfb-7035f56326d9"}},
want: dbx.Or(dbx.HashExp{"uuid": "9e5081e6-4c56-57b9-9965-f6dc74559755"}, dbx.HashExp{"uuid": "48cd8c4e-fb70-595c-9dfb-7035f56326d9"}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildIcalQueryForModules(tt.args.modules); !reflect.DeepEqual(got, tt.want) {
t.Errorf("buildIcalQueryForModules() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,107 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"errors"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"htwkalender/data-manager/model"
"time"
)
func SaveFeed(feed model.Feed, collection *models.Collection, app *pocketbase.PocketBase) (*models.Record, error) {
record := models.NewRecord(collection)
record.Set("modules", feed.Modules)
err := app.Dao().SaveRecord(record)
if err != nil {
return nil, err
}
return record, nil
}
func FindFeedByToken(app *pocketbase.PocketBase, token string) (*model.Feed, error) {
record, err := app.Dao().FindRecordById("feeds", token)
if err != nil {
return nil, err
}
var feed model.Feed
feed.Modules = record.GetString("modules")
feed.Retrieved = record.GetDateTime("retrieved")
feed.Deleted = record.GetBool("deleted")
//update retrieved time if the is not marked as deleted
if !record.GetBool("deleted") {
record.Set("retrieved", time.Now())
err = app.Dao().SaveRecord(record)
}
return &feed, err
}
func DeleteFeed(db *daos.Dao, feedId string) error {
sqlResult, err := db.DB().Delete("feeds", dbx.NewExp("id = {:id}", dbx.Params{"id": feedId})).Execute()
var deletedRows int64
if sqlResult != nil {
deletedRows, _ = sqlResult.RowsAffected()
}
if err != nil {
return err
} else {
if deletedRows == 0 {
return errors.New("No feed with id " + feedId + " found")
} else {
return nil
}
}
}
func GetAllFeeds(db *daos.Dao) ([]model.Feed, error) {
var feeds []model.Feed
err := db.DB().Select("*").From("feeds").All(&feeds)
if err != nil {
return nil, err
}
return feeds, nil
}
func MarkForDelete(db *daos.Dao, token string) error {
// get record from db
feed := model.Feed{}
err := db.DB().Select("*").From("feeds").Where(dbx.NewExp("id = {:id}", dbx.Params{"id": token})).One(&feed)
if err != nil {
return err
}
// set delete flag
feed.Deleted = true
feed.Modules = "[\n {\n \"uuid\": \"\",\n \"name\": \"Deleted\",\n \"course\": \"\",\n \"userDefinedName\": \"Deleted\",\n \"reminder\": false\n }\n]"
// save record
err = db.Save(&feed)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,71 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"testing"
)
const testDataDir = "./mockData"
func TestDeleteFeed(t *testing.T) {
setupTestApp := func(t *testing.T) *daos.Dao {
testApp, err := tests.NewTestApp(testDataDir)
if err != nil {
t.Fatal(err)
}
dao := daos.New(testApp.Dao().DB())
return dao
}
type args struct {
db *daos.Dao
feedId string
}
testsCases := []struct {
name string
args args
wantErr bool
}{
{
name: "TestDeleteFeed",
args: args{
db: setupTestApp(t),
feedId: "fkoqti06ohlnsb8",
},
wantErr: false,
},
{
name: "TestDeleteFeedNotExisting",
args: args{
db: setupTestApp(t),
feedId: "test324",
},
wantErr: true,
},
}
for _, tt := range testsCases {
t.Run(tt.name, func(t *testing.T) {
if err := DeleteFeed(tt.args.db, tt.args.feedId); (err != nil) != tt.wantErr {
t.Errorf("DeleteFeed() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,27 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/models"
)
func FindCollection(app *pocketbase.PocketBase, collectionName string) (*models.Collection, error) {
collection, dbError := app.Dao().FindCollectionByNameOrId(collectionName)
return collection, dbError
}

View File

@@ -0,0 +1,148 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/models"
"htwkalender/data-manager/model"
"log/slog"
)
type SeminarGroup struct {
University string `db:"university" json:"university"`
GroupShortcut string `db:"shortcut" json:"shortcut"`
GroupId string `db:"groupId" json:"groupId"`
Course string `db:"course" json:"course"`
Faculty string `db:"faculty" json:"faculty"`
FacultyId string `db:"facultyId" json:"facultyId"`
Semester string `db:"semester" json:"semester"`
models.BaseModel
}
func (s *SeminarGroup) TableName() string {
return "groups"
}
// UniqueKey Should be same as unique constraint in the database
func (s *SeminarGroup) UniqueKey() string {
return s.Course + s.Semester
}
func (s *SeminarGroup) toSeminarGroupModel() model.SeminarGroup {
return model.SeminarGroup{
University: s.University,
GroupShortcut: s.GroupShortcut,
GroupId: s.GroupId,
Course: s.Course,
Faculty: s.Faculty,
FacultyId: s.FacultyId,
Semester: s.Semester,
}
}
func (s *SeminarGroups) toSeminarGroupModels() []model.SeminarGroup {
var seminarGroups []model.SeminarGroup
for _, group := range *s {
seminarGroups = append(seminarGroups, group.toSeminarGroupModel())
}
return seminarGroups
}
type SeminarGroups []*SeminarGroup
func SaveGroups(seminarGroups SeminarGroups, app *pocketbase.PocketBase) (SeminarGroups, error) {
// delete all groups from the database
execute, err := app.Dao().DB().Delete("groups", dbx.NewExp("1 = 1")).Execute()
if err != nil {
return nil, err
}
rowCount, _ := execute.RowsAffected()
savedGroups := SeminarGroups{}
for _, group := range seminarGroups {
saveErr := app.Dao().Save(group)
if saveErr != nil {
return nil, saveErr
}
savedGroups = append(savedGroups, group)
}
slog.Info("Saved all groups to the database", "insert", len(savedGroups), "deleted", rowCount)
return savedGroups, nil
}
func GetAllCourses(app *pocketbase.PocketBase) []string {
var courses []struct {
CourseShortcut string `db:"course" json:"course"`
}
// get all rooms from event records in the events collection
err := app.Dao().DB().Select("course").From("groups").All(&courses)
if err != nil {
slog.Error("Error while getting groups from database: ", "error", err)
return []string{}
}
var courseArray []string
for _, course := range courses {
courseArray = append(courseArray, course.CourseShortcut)
}
return courseArray
}
func GetAllCoursesForSemester(app *pocketbase.PocketBase, semester string) []model.SeminarGroup {
var courses SeminarGroups
// get all courses for a specific semester
err := app.Dao().DB().Select("*").From("groups").Where(dbx.NewExp("semester = {:semester}", dbx.Params{"semester": semester})).All(&courses)
if err != nil {
slog.Error("Error while getting groups from database: ", "error", err)
return nil
}
return courses.toSeminarGroupModels()
}
func GetAllCoursesForSemesterWithEvents(app *pocketbase.PocketBase, semester string) ([]string, error) {
var courses []struct {
CourseShortcut string `db:"course" json:"course"`
}
// get all courses from events distinct for a specific semester
err := app.Dao().DB().Select("course").From("events").Where(dbx.NewExp("semester = {:semester}", dbx.Params{"semester": semester})).Distinct(true).All(&courses)
if err != nil {
slog.Error("Error while getting groups from database: ", "error", err)
return nil, err
}
var courseArray []string
for _, course := range courses {
courseArray = append(courseArray, course.CourseShortcut)
}
return courseArray, nil
}

View File

@@ -0,0 +1,120 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package db
import (
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/functions"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
)
func GetRooms(app *pocketbase.PocketBase) ([]string, error) {
var events []struct {
Rooms string `db:"Rooms" json:"Rooms"`
Course string `db:"course" json:"Course"`
}
// get all rooms from event records in the events collection
err := app.Dao().DB().Select("Rooms", "course").From("events").Distinct(true).All(&events)
if err != nil {
return nil, err
}
roomArray, err := clearAndSeparateRooms([]struct {
Rooms string
Course string
}(events))
if err != nil {
return nil, err
}
return roomArray, nil
}
func clearAndSeparateRooms(events []struct {
Rooms string
Course string
}) ([]string, error) {
var roomArray []string
for _, event := range events {
var room []string
// sport rooms don't have to be separated
if event.Course != "Sport" {
//split rooms by comma, tab, newline, carriage return, semicolon, space and non-breaking space
room = functions.SeperateRoomString(event.Rooms)
} else {
room = append(room, event.Rooms)
}
//split functions room by space and add each room to array if it is not already in there
for _, r := range room {
var text = strings.TrimSpace(r)
if !functions.Contains(roomArray, text) && len(text) >= 1 {
roomArray = append(roomArray, text)
}
}
}
return roomArray, nil
}
func GetRoomScheduleForDay(app *pocketbase.PocketBase, room string, date string) ([]model.Event, error) {
var events []model.Event
// get all events from event records in the events collection
err := app.Dao().DB().Select("*").From("events").
Where(dbx.Like("Rooms", room).Escape("_", "_")).
AndWhere(dbx.Like("Start", date)).
GroupBy("Week", "Start", "End", "Rooms").
All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetRoomSchedule(app *pocketbase.PocketBase, room string, from string, to string) ([]model.Event, error) {
var events []model.Event
fromDate, err := time.Parse("2006-01-02", from)
if err != nil {
return nil, err
}
toDate, err := time.Parse("2006-01-02", to)
if err != nil {
return nil, err
}
// get all events from event records in the events collection
err = app.Dao().DB().Select("*").From("events").
Where(dbx.Like("Rooms", room).Escape("_", "_")).
AndWhere(dbx.Between("Start", fromDate, toDate)).
GroupBy("Week", "Start", "End", "Rooms").
All(&events)
if err != nil {
return nil, err
}
return events, nil
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,74 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import (
"github.com/pocketbase/pocketbase"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
"htwkalender/data-manager/service/functions"
)
// CourseService defines the methods to be implemented
type CourseService interface {
GetAllCourses() []string
GetAllCoursesForSemester(semester string) []model.SeminarGroup
GetAllCoursesForSemesterWithEvents(semester string) ([]string, error)
}
// PocketBaseCourseService is a struct that implements the CourseService interface
type PocketBaseCourseService struct {
app *pocketbase.PocketBase
}
// NewPocketBaseCourseService creates a new PocketBaseCourseService
func NewPocketBaseCourseService(app *pocketbase.PocketBase) *PocketBaseCourseService {
return &PocketBaseCourseService{app: app}
}
// GetAllCourses returns all courses
func (s *PocketBaseCourseService) GetAllCourses() []string {
return db.GetAllCourses(s.app)
}
// GetAllCoursesForSemester returns all courses for a specific semester
func (s *PocketBaseCourseService) GetAllCoursesForSemester(semester string) []model.SeminarGroup {
return db.GetAllCoursesForSemester(s.app, semester)
}
// GetAllCoursesForSemesterWithEvents returns all courses for a specific semester with events
func (s *PocketBaseCourseService) GetAllCoursesForSemesterWithEvents(semester string) ([]string, error) {
courses, err := db.GetAllCoursesForSemesterWithEvents(s.app, semester)
if err != nil {
return nil, err
}
// remove empty courses like " " or ""
courses = removeEmptyCourses(courses)
return courses, nil
}
// removeEmptyCourses removes empty courses from the list of courses
func removeEmptyCourses(courses []string) []string {
var filteredCourses []string
for _, course := range courses {
if !functions.OnlyWhitespace(course) || len(course) != 0 {
filteredCourses = append(filteredCourses, course)
}
}
return filteredCourses
}

View File

@@ -0,0 +1,55 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import (
"reflect"
"testing"
)
func TestRemoveEmptyCourses(t *testing.T) {
type args struct {
courses []string
}
tests := []struct {
name string
args args
want []string
}{
{
name: "Test remove empty courses",
args: args{
courses: []string{"", "test", "test2", ""},
},
want: []string{"test", "test2"},
},
{
name: "Test remove empty courses",
args: args{
courses: []string{"", "test", "test2", "", "test3"},
},
want: []string{"test", "test2", "test3"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := removeEmptyCourses(tt.args.courses); !reflect.DeepEqual(got, tt.want) {
t.Errorf("removeEmptyCourses() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,232 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package events
import (
"github.com/pocketbase/pocketbase"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
"htwkalender/data-manager/service/fetch/v1"
"htwkalender/data-manager/service/functions"
"log/slog"
"strconv"
)
type EventService interface {
GetModulesForCourseDistinct(course string, semester string) (model.Events, error)
GetAllModulesDistinct() ([]model.ModuleDTO, error)
GetModuleByUUID(uuid string) (model.Module, error)
DeleteAllEventsByCourseAndSemester(course string, semester string) error
DeleteAllEvents() error
UpdateModulesForCourse(seminarGroup model.SeminarGroup) (model.Events, error)
GetEventTypes() ([]string, error)
}
type Named interface {
GetName() string
SetName(name string)
}
type PocketBaseEventService struct {
app *pocketbase.PocketBase
}
func NewPocketBaseEventService(app *pocketbase.PocketBase) *PocketBaseEventService {
return &PocketBaseEventService{app: app}
}
func (s *PocketBaseEventService) GetModulesForCourseDistinct(course string, semester string) (model.Events, error) {
modules, err := db.GetAllModulesForCourse(s.app, course, semester)
// Convert the []model.Module to []Named
var namedEvents []Named
for _, module := range modules {
namedEvents = append(namedEvents, &module)
}
replaceEmptyEntry(namedEvents, "Sonderveranstaltungen")
return modules, err
}
// replaceEmptyEntry replaces an empty entry in a module with a replacement string
// If the module is not empty, nothing happens
func replaceEmptyEntry(namedList []Named, replacement string) {
for i, namedItem := range namedList {
if functions.OnlyWhitespace(namedItem.GetName()) {
namedList[i].SetName(replacement)
}
}
}
// GetAllModulesDistinct returns all modules distinct by name and course from the database
// That means you get all modules with duplicates if they have different courses
func (s *PocketBaseEventService) GetAllModulesDistinct() ([]model.ModuleDTO, error) {
modules, err := db.GetAllModulesDistinctByNameAndCourse(s.app)
if err != nil {
return nil, err
}
var namedModules []Named
for _, module := range modules {
namedModules = append(namedModules, &module)
}
replaceEmptyEntry(namedModules, "Sonderveranstaltungen")
return modules, nil
}
func (s *PocketBaseEventService) GetModuleByUUID(uuid string) (model.Module, error) {
module, findModuleErr := db.FindModuleByUUID(s.app, uuid)
if findModuleErr != nil {
return model.Module{}, findModuleErr
}
events, findEventsError := db.FindAllEventsByModule(s.app, module)
if findEventsError != nil || len(events) == 0 {
return model.Module{}, findEventsError
} else {
return model.Module{
UUID: events[0].UUID,
Name: events[0].Name,
Events: events,
Prof: events[0].Prof,
Course: events[0].Course,
Semester: events[0].Semester,
}, nil
}
}
// DeleteAllEventsByCourseAndSemester deletes all events for a course and a semester
// If the deletion was successful, nil is returned
// If the deletion was not successful, an error is returned
func (s *PocketBaseEventService) DeleteAllEventsByCourseAndSemester(course string, semester string) error {
err := db.DeleteAllEventsByCourse(s.app.Dao(), course, semester)
if err != nil {
return err
} else {
return nil
}
}
func (s *PocketBaseEventService) DeleteAllEvents() error {
err := db.DeleteAllEvents(s.app)
if err != nil {
return err
} else {
return nil
}
}
// UpdateModulesForCourse updates all modules for a course
// Does Updates for ws and ss semester sequentially
// Update runs through the following steps:
// 1. Delete all events for the course and the semester
// 2. Fetch all events for the course and the semester
// 3. Save all events for the course and the semester
// If the update was successful, nil is returned
// If the update was not successful, an error is returned
func (s *PocketBaseEventService) UpdateModulesForCourse(seminarGroup model.SeminarGroup) (model.Events, error) {
seminarGroup, err := v1.FetchAndParse(seminarGroup.Semester, seminarGroup.Course)
if err != nil {
return nil, err
}
seminarGroup = v1.ReplaceEmptyEventNames(seminarGroup)
//check if events in the seminarGroups Events are already in the database
//if yes, keep the database as it is
//if no, delete all events for the course and the semester and save the new events
//if there are no events in the database, save the new events
//get all events for the course and the semester
dbEvents, err := db.GetAllEventsForCourse(s.app, seminarGroup.Course)
if err != nil {
return nil, err
}
//if there are no events in the database, save the new events
if len(dbEvents) == 0 {
events, dbError := db.SaveSeminarGroupEvents(seminarGroup, s.app)
if dbError != nil {
return nil, dbError
}
return events, nil
}
// Create partial update list and delete list for the events
var insertList model.Events
var deleteList model.Events
// check which events are not already in the database and need to be inserted/saved
for _, event := range seminarGroup.Events {
if !containsEvent(dbEvents, event) {
insertList = append(insertList, event)
}
}
// check which events are in the database but not in the seminarGroup and need to be deleted
for _, dbEvent := range dbEvents {
if !containsEvent(seminarGroup.Events, dbEvent) {
deleteList = append(deleteList, dbEvent)
}
}
// delete all events that are in the deleteList
err = db.DeleteEvents(deleteList, s.app)
if err != nil {
slog.Error("Failed to delete events:", "error", err)
return nil, err
}
// save all events that are in the insertList
savedEvents, err := db.SaveEvents(insertList, s.app.Dao())
if err != nil {
slog.Error("Failed to save events: ", "error", err)
return nil, err
}
slog.Info("Course: " + seminarGroup.Course + " - Event changes: " + strconv.FormatInt(int64(len(insertList)), 10) + " new events, " + strconv.FormatInt(int64(len(deleteList)), 10) + " deleted events")
return savedEvents, nil
}
func containsEvent(events model.Events, event model.Event) bool {
for _, e := range events {
if e.Name == event.Name &&
e.Prof == event.Prof &&
e.Rooms == event.Rooms &&
e.Semester == event.Semester &&
e.Start == event.Start &&
e.End == event.End &&
e.Course == event.Course {
return true
}
}
return false
}
func (s *PocketBaseEventService) GetEventTypes() ([]string, error) {
dbEventTypes, err := db.GetAllEventTypes(s.app)
if err != nil {
return nil, err
}
// Convert the []model.EventType to []string
var eventTypes []string
for _, eventType := range dbEventTypes {
eventTypes = append(eventTypes, eventType.EventType)
}
return eventTypes, nil
}

View File

@@ -0,0 +1,58 @@
package events
import (
"htwkalender/data-manager/model"
"testing"
)
func TestContainsEvent(t *testing.T) {
type args struct {
events model.Events
event model.Event
}
tests := []struct {
name string
args args
want bool
}{
{
name: "contains event",
args: args{
events: model.Events{
{
UUID: "934807509832475",
Name: "name",
},
},
event: model.Event{
UUID: "934807509832475",
Name: "name",
},
},
want: true,
},
{
name: "contains no event",
args: args{
events: model.Events{
{
UUID: "9991929292921912343534",
Name: "Name1",
},
},
event: model.Event{
UUID: "1111112312312312",
Name: "Name2",
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := containsEvent(tt.args.events, tt.args.event); got != tt.want {
t.Errorf("containsEvent() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,66 @@
package mock
import (
"github.com/stretchr/testify/mock"
"htwkalender/data-manager/model"
)
// MockCourseService is a mock implementation of the CourseService interface
type MockCourseService struct {
mock.Mock
}
func (m *MockCourseService) GetAllCourses() []string {
args := m.Called()
return args.Get(0).([]string)
}
func (m *MockCourseService) GetAllCoursesForSemester(semester string) []model.SeminarGroup {
args := m.Called(semester)
return args.Get(0).([]model.SeminarGroup)
}
func (m *MockCourseService) GetAllCoursesForSemesterWithEvents(semester string) ([]string, error) {
args := m.Called(semester)
return args.Get(0).([]string), args.Error(1)
}
// MockEventService is a mock implementation of the EventService interface
type MockEventService struct {
mock.Mock
}
func (m *MockEventService) GetModulesForCourseDistinct(course string, semester string) (model.Events, error) {
args := m.Called(course, semester)
return args.Get(0).(model.Events), args.Error(1)
}
func (m *MockEventService) GetAllModulesDistinct() ([]model.ModuleDTO, error) {
args := m.Called()
return args.Get(0).([]model.ModuleDTO), args.Error(1)
}
func (m *MockEventService) GetModuleByUUID(uuid string) (model.Module, error) {
args := m.Called(uuid)
return args.Get(0).(model.Module), args.Error(1)
}
func (m *MockEventService) DeleteAllEventsByCourseAndSemester(course string, semester string) error {
args := m.Called(course, semester)
return args.Error(0)
}
func (m *MockEventService) DeleteAllEvents() error {
args := m.Called()
return args.Error(0)
}
func (m *MockEventService) UpdateModulesForCourse(seminarGroup model.SeminarGroup) (model.Events, error) {
args := m.Called(seminarGroup)
return args.Get(0).(model.Events), args.Error(1)
}
func (m *MockEventService) GetEventTypes() ([]string, error) {
args := m.Called()
return args.Get(0).([]string), args.Error(1)
}

View File

@@ -0,0 +1,124 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package feed
import (
"github.com/pocketbase/pocketbase/daos"
"htwkalender/data-manager/model"
database "htwkalender/data-manager/service/db"
localTime "htwkalender/data-manager/service/functions/time"
"log/slog"
"strings"
)
func ClearFeeds(db *daos.Dao, months int, clock localTime.Clock) {
feeds, err := database.GetAllFeeds(db)
if err != nil {
slog.Error("CleanFeeds: failed to get all feeds", "error", err)
return
}
for _, feed := range feeds {
// if retrieved time is older than a half year delete feed
now := clock.Now()
feedRetrievedTime := feed.Retrieved.Time()
timeShift := now.AddDate(0, -months, 0)
if feedRetrievedTime.Before(timeShift) {
// delete feed
feedErr := database.DeleteFeed(db, feed.GetId())
if feedErr != nil {
slog.Error("CleanFeeds: failed to delete feed: "+feed.GetId(), "error", feedErr)
}
}
}
}
func CombineEventsInFeed(events model.Events) model.Events {
// Combine events with the same name, start, end and course
// check if there are events
if len(events) > 0 {
combinedEvents := model.Events{events[0]}
for i := 1; i < len(events); i++ {
// check if the event is already in the combinedEvents
alreadyInCombinedEvents := false
for j := 0; j < len(combinedEvents); j++ {
if events[i].Name == combinedEvents[j].Name &&
events[i].Start == combinedEvents[j].Start &&
events[i].End == combinedEvents[j].End &&
events[i].Course == combinedEvents[j].Course {
alreadyInCombinedEvents = true
combinedEvents[j].Notes = addNotesIfAlreadyRoomsAdded(events, combinedEvents, j, i)
combinedEvents[j].Prof = combineProfs(events, i, combinedEvents, j)
combinedEvents[j].Rooms = combineRooms(events, i, combinedEvents, j)
break
}
}
if !alreadyInCombinedEvents {
combinedEvents = append(combinedEvents, events[i])
}
}
return combinedEvents
}
return model.Events{}
}
func addNotesIfAlreadyRoomsAdded(events model.Events, combinedEvents model.Events, index2 int, index1 int) string {
// check if combinedEvents[index2].Rooms string contains comma "," &#44
if !strings.Contains(combinedEvents[index2].Rooms, ",") {
return descriptionString(combinedEvents[index2]) + "\n" + descriptionString(events[index1])
} else {
return combinedEvents[index2].Notes + "\n" + descriptionString(events[index1])
}
}
func combineProfs(events model.Events, index1 int, combinedEvents model.Events, index2 int) string {
// combine the profs
if events[index1].Prof != "" {
if combinedEvents[index2].Prof == "" {
return events[index1].Prof
} else {
if !strings.Contains(combinedEvents[index2].Prof, events[index1].Prof) {
return combinedEvents[index2].Prof + ", " + events[index1].Prof
}
}
}
return combinedEvents[index2].Prof
}
func descriptionString(event model.Event) string {
return event.Rooms + " - " + event.Notes + " (" + event.Prof + ")"
}
func combineRooms(events model.Events, index1 int, combinedEvents model.Events, index2 int) string {
// combine the rooms
if events[index1].Rooms != "" {
if combinedEvents[index2].Rooms == "" {
return events[index1].Rooms
} else {
if !strings.Contains(combinedEvents[index2].Rooms, events[index1].Rooms) {
return combinedEvents[index2].Rooms + ", " + events[index1].Rooms
}
}
}
return combinedEvents[index2].Rooms
}
func MarkFeedForDeletion(db *daos.Dao, feedId string) error {
return database.MarkForDelete(db, feedId)
}

View File

@@ -0,0 +1,200 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package feed
import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"htwkalender/data-manager/model"
mockTime "htwkalender/data-manager/service/functions/time"
"reflect"
"testing"
"time"
)
const testDataDir = "./mockData"
func TestClearFeeds(t *testing.T) {
setupTestApp := func(t *testing.T) *daos.Dao {
testApp, err := tests.NewTestApp(testDataDir)
if err != nil {
t.Fatal(err)
}
dao := daos.New(testApp.Dao().DB())
return dao
}
type args struct {
db *daos.Dao
months int
mockClock mockTime.MockClock
}
testCases := []struct {
name string
args args
want int
}{
{
name: "TestClearFeeds",
args: args{
db: setupTestApp(t),
months: 6,
mockClock: mockTime.MockClock{
NowTime: time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC),
},
},
want: 1,
},
{
name: "TestClearAllFeeds",
args: args{
db: setupTestApp(t),
months: 1,
mockClock: mockTime.MockClock{
NowTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
want: 0,
},
{
name: "TestClearFeedsClearBeforeRetrievedTime",
args: args{
db: setupTestApp(t),
months: 1,
mockClock: mockTime.MockClock{
NowTime: time.Date(2010, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
want: 3,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ClearFeeds(tt.args.db, tt.args.months, tt.args.mockClock)
// count all feeds in db
var feeds []*model.Feed
err := tt.args.db.DB().Select("id").From("feeds").All(&feeds)
if err != nil {
t.Fatal(err)
}
if got := len(feeds); got != tt.want {
t.Errorf("ClearFeeds() = %v, want %v", got, tt.want)
}
})
}
}
func TestCombineEventsInFeed(t *testing.T) {
type args struct {
events model.Events
}
testCases := []struct {
name string
args args
want model.Events
}{
{
name: "TestCombineEventsInFeed",
args: args{
events: model.Events{
{
Name: "Modellierung",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI001",
Notes: "Gruppe 2",
},
{
Name: "Modellierung",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI002",
Notes: "Gruppe 1",
},
},
},
want: model.Events{
{
Name: "Modellierung",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI001, LI002",
Notes: "LI001 - Gruppe 2 (Prof. Bunt)\nLI002 - Gruppe 1 (Prof. Bunt)",
},
},
},
{
name: "CannotCombineEventsInFeed",
args: args{
events: model.Events{
{
Name: "Modellierung",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI001",
Notes: "Gruppe 2",
},
{
Name: "Modellierung - 2",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI002",
Notes: "Gruppe 1",
},
},
},
want: model.Events{
{
Name: "Modellierung",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI001",
Notes: "Gruppe 2",
},
{
Name: "Modellierung - 2",
Start: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)),
End: mockTime.ParseAsTypesDatetime(time.Date(2023, 12, 1, 4, 0, 0, 0, time.UTC)),
Prof: "Prof. Bunt",
Rooms: "LI002",
Notes: "Gruppe 1",
},
},
},
{
name: "NoEventsInFeed",
args: args{
events: model.Events{},
},
want: model.Events{},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
if got := CombineEventsInFeed(tt.args.events); !reflect.DeepEqual(got, tt.want) {
t.Errorf("CombineEventsInFeed() = %v, want %v", got, tt.want)
}
})
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,76 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package fetch
import (
"fmt"
"io"
"log/slog"
"net/http"
"time"
)
// getPlanHTML Get the HTML document from the specified URL
func GetHTML(url string) (string, error) {
// Create HTTP client with timeout of 5 seconds
client := http.Client{
Timeout: 30 * time.Second,
}
return GetHTMLWithClient(url, &client)
}
func GetHTMLWithClient(url string, client *http.Client) (string, error) {
// Send GET request
response, err := client.Get(url)
if err != nil {
slog.Error("Error occurred while fetching the HTML document:", "error", err)
return "", err
}
if response.StatusCode != 200 {
slog.Warn("While fetching the HTML document, the server responded with status code: ", "status", response.StatusCode)
return "", fmt.Errorf("server responded with status code: %d", response.StatusCode)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
return
}
}(response.Body)
// Read the response body
body, err := io.ReadAll(response.Body)
if err != nil {
fmt.Printf("Error occurred while reading the response: %s\n", err.Error())
return "", err
}
return toUtf8(body), err
}
func toUtf8(iso88591Buf []byte) string {
buf := make([]rune, len(iso88591Buf))
for i, b := range iso88591Buf {
buf[i] = rune(b)
}
return string(buf)
}

View File

@@ -0,0 +1,63 @@
package fetch
import (
"github.com/jarcoal/httpmock"
"net/http"
"testing"
)
func TestGetHTMLWithClient(t *testing.T) {
client := &http.Client{}
httpmock.ActivateNonDefault(client)
type args struct {
url string
statusCode int
method string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "Test GetHTML with status code 200",
args: args{
url: "https://stundenplan.htwk-leipzig.de/ss/Berichte/Text-Listen;Studenten-Sets;name;1-1?template=sws_semgrp&weeks=1-65",
method: "GET",
statusCode: 200,
},
want: "",
wantErr: false,
},
{
name: "Test GetHTML with status code 404",
args: args{
url: "https://stundenplan.htwk-leipzig.de/ss/Berichte/Text-Lists;Studenten-Sets;name;1-1?template=sws_semgrp&weeks=1-65",
method: "GET",
statusCode: 404,
},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpmock.RegisterResponder(tt.args.method, tt.args.url,
httpmock.NewStringResponder(tt.args.statusCode, tt.want))
got, err := GetHTMLWithClient(tt.args.url, client)
if (err != nil) != tt.wantErr {
t.Errorf("GetHTML() error = %v, wantNoErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetHTML() got = %v, want %v", got, tt.want)
}
})
}
httpmock.DeactivateAndReset()
}

View File

@@ -0,0 +1,572 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package sport
import (
"errors"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/tools/types"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
"htwkalender/data-manager/service/functions"
clock "htwkalender/data-manager/service/functions/time"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
)
// FetchAndUpdateSportEvents fetches all sport events from the HTWK sport website
// it deletes them first and then saves them to the database
// It returns all saved events
func FetchAndUpdateSportEvents(app *pocketbase.PocketBase) ([]model.Event, error) {
sportCourseLinks, err := fetchAllAvailableSportCourses()
if err != nil {
return nil, err
}
sportEntries := fetchHTWKSportCourses(sportCourseLinks)
events := formatEntriesToEvents(sportEntries)
var earliestDate time.Time
var latestDate time.Time
// find earliest and latest date in events
for _, event := range events {
if event.Start.Time().Before(earliestDate) {
earliestDate = event.Start.Time()
}
if event.End.Time().After(latestDate) {
latestDate = event.End.Time()
}
}
// get all events from database where name = Feiertage und lehrveranstaltungsfreie Tage
holidays, err := db.GetAllModulesByNameAndDateRange(app, "Feiertage und lehrveranstaltungsfreie Tage", earliestDate, latestDate)
if err != nil {
return nil, err
}
// remove all events that have same year, month and day as items in holidays
for _, holiday := range holidays {
for i, event := range events {
if event.Start.Time().Year() == holiday.Start.Time().Year() &&
event.Start.Time().Month() == holiday.Start.Time().Month() &&
event.Start.Time().Day() == holiday.Start.Time().Day() {
events = append(events[:i], events[i+1:]...)
}
}
}
// @TODO: delete and save events in one transaction and it only should delete events that are not in the new events list and save events that are not in the database
err = db.DeleteAllEventsByCourse(app.Dao(), "Sport", functions.GetCurrentSemesterString(clock.RealClock{}))
if err != nil {
return nil, err
}
// save events to database
savedEvents, err := db.SaveEvents(events, app.Dao())
if err != nil {
return nil, err
}
return savedEvents, nil
}
func formatEntriesToEvents(entries []model.SportEntry) []model.Event {
var events []model.Event
for _, entry := range entries {
eventStarts, eventEnds := getWeekEvents(entry.Details.DateRange.Start, entry.Details.DateRange.End, entry.Details.Cycle)
for j := range eventStarts {
start, _ := types.ParseDateTime(eventStarts[j].In(time.UTC))
end, _ := types.ParseDateTime(eventEnds[j].In(time.UTC))
var event = model.Event{
UUID: uuid.NewSHA1(uuid.NameSpaceDNS, []byte(entry.Title+entry.ID+entry.Details.Type)).String(),
Day: toGermanWeekdayString(start.Time().Weekday()),
Week: strconv.Itoa(23),
Start: start,
End: end,
Name: entry.Title + " (" + entry.ID + ")",
EventType: entry.Details.Type,
Prof: entry.Details.CourseLead.Name,
Rooms: entry.Details.Location.Name,
Notes: entry.AdditionalNote,
BookedAt: "",
Course: "Sport",
Semester: checkSemester(entry.Details.DateRange.Start),
}
events = append(events, event)
}
}
return events
}
func getDayInt(weekDay string) (int, error) {
var weekDayInt int
var err error = nil
switch weekDay {
case "Mo":
weekDayInt = 1
case "Di":
weekDayInt = 2
case "Mi":
weekDayInt = 3
case "Do":
weekDayInt = 4
case "Fr":
weekDayInt = 5
case "Sa":
weekDayInt = 6
case "So":
weekDayInt = 0
default:
{
err = errors.New("no day found")
weekDayInt = -1
}
}
return weekDayInt, err
}
func toGermanWeekdayString(weekday time.Weekday) string {
switch weekday {
case time.Monday:
return "Montag"
case time.Tuesday:
return "Dienstag"
case time.Wednesday:
return "Mittwoch"
case time.Thursday:
return "Donnerstag"
case time.Friday:
return "Freitag"
case time.Saturday:
return "Samstag"
case time.Sunday:
return "Sonntag"
default:
return ""
}
}
func extractStartAndEndTime(cycle string) (int, int, int, int) {
timeRegExp, _ := regexp.Compile("[0-9]{2}:[0-9]{2}")
times := timeRegExp.FindAllString(cycle, 2)
startHour, _ := strconv.Atoi(times[0][0:2])
startMinute, _ := strconv.Atoi(times[0][3:5])
endHour, _ := strconv.Atoi(times[1][0:2])
endMinute, _ := strconv.Atoi(times[1][3:5])
return startHour, startMinute, endHour, endMinute
}
func getWeekEvents(start time.Time, end time.Time, cycle string) ([]time.Time, []time.Time) {
var weekEvents []model.SportDayStartEnd
// split by regexp to get the cycle parts
var cycleParts = splitByCommaWithTime(cycle)
for _, cyclePart := range cycleParts {
//cut string at the first integer/number
cyclePartWithDaysOnly := cyclePart[0:strings.IndexFunc(cyclePart, func(r rune) bool { return r >= '0' && r <= '9' })]
// check if cycle has multiple days by checking if it has a plus sign
if strings.Contains(cyclePartWithDaysOnly, "+") {
// find all days in cycle part by regexp
dayRegExp, _ := regexp.Compile("[A-Z][a-z]")
days := dayRegExp.FindAllString(cyclePart, -1)
startHour, startMinute, endHour, endMinute := extractStartAndEndTime(cyclePart)
// creating a SportDayStartEnd for each day in the cycle
for _, day := range days {
weekDay, err := getDayInt(day)
if err != nil {
slog.Error("Error while getting day int: "+day+" ", "error", err)
} else {
weekEvents = append(weekEvents, model.SportDayStartEnd{
Start: time.Date(start.Year(), start.Month(), start.Day(), startHour, startMinute, 0, 0, start.Location()),
End: time.Date(end.Year(), end.Month(), end.Day(), endHour, endMinute, 0, 0, end.Location()),
Day: time.Weekday(weekDay),
})
}
}
}
// check if cycle has multiple days by checking if it has a minus sign
if strings.Contains(cyclePartWithDaysOnly, "-") {
// find all days in cycle part by regexp
dayRegExp, _ := regexp.Compile("[A-Z][a-z]")
days := dayRegExp.FindAllString(cyclePart, 2)
startHour, startMinute, endHour, endMinute := extractStartAndEndTime(cyclePart)
var startI, endI int
var endIErr, startIErr error
startI, startIErr = getDayInt(days[0])
endI, endIErr = getDayInt(days[1])
if endIErr != nil || startIErr != nil {
slog.Error("StartError while getting day int: "+days[0]+" - "+days[1]+" :", "error", startIErr)
slog.Error("EndError while getting day int: "+days[0]+" - "+days[1]+" :", "error", endIErr)
} else {
//create an int array with all days from start to end day
var daysBetween []int
for i := startI; i <= endI; i++ {
daysBetween = append(daysBetween, i)
}
// creating a SportDayStartEnd for each day in the cycle
weekEvents = createEventListFromStartToEndMatchingDay23(daysBetween, start, startHour, startMinute, end, endHour, endMinute)
}
}
// check if cycle has only one day
if !strings.Contains(cyclePartWithDaysOnly, "-") && !strings.Contains(cyclePartWithDaysOnly, "+") {
// find all days in cycle part by regexp
dayRegExp, _ := regexp.Compile("[A-Z][a-z]")
days := dayRegExp.FindAllString(cyclePart, -1)
startHour, startMinute, endHour, endMinute := extractStartAndEndTime(cyclePart)
var dayNumbers []int
for _, day := range days {
dayInt, err := getDayInt(day)
if err != nil {
slog.Error("Error while getting day int: "+day+" ", "error", err)
} else {
dayNumbers = append(dayNumbers, dayInt)
}
}
// creating a SportDayStartEnd for each day in the cycle
weekEvents = append(weekEvents, createEventListFromStartToEndMatchingDay23(dayNumbers, start, startHour, startMinute, end, endHour, endMinute)...)
for _, day := range days {
weekDay, err := getDayInt(day)
if err != nil {
slog.Error("Error while getting day int: "+day+" ", "error", err)
} else {
weekEvents = append(weekEvents, model.SportDayStartEnd{
Start: time.Date(start.Year(), start.Month(), start.Day(), startHour, startMinute, 0, 0, start.Location()),
End: time.Date(end.Year(), end.Month(), end.Day(), endHour, endMinute, 0, 0, end.Location()),
Day: time.Weekday(weekDay),
})
}
}
}
}
var startDatesList []time.Time
var endDatesList []time.Time
for _, weekEvent := range weekEvents {
startDates, endDates := createEventListFromStartToEndMatchingDay(weekEvent)
startDatesList = append(startDatesList, startDates...)
endDatesList = append(endDatesList, endDates...)
}
return startDatesList, endDatesList
}
// creating a SportDayStartEnd for each day in the cycle
func createEventListFromStartToEndMatchingDay23(days []int, start time.Time, startHour int, startMinute int, end time.Time, endHour int, endMinute int) []model.SportDayStartEnd {
var weekEvents []model.SportDayStartEnd
for _, day := range days {
weekEvents = append(weekEvents, model.SportDayStartEnd{
Start: time.Date(start.Year(), start.Month(), start.Day(), startHour, startMinute, 0, 0, start.Location()),
End: time.Date(end.Year(), end.Month(), end.Day(), endHour, endMinute, 0, 0, end.Location()),
Day: time.Weekday(day),
})
}
return weekEvents
}
func createEventListFromStartToEndMatchingDay(weekEvent model.SportDayStartEnd) ([]time.Time, []time.Time) {
var startDates []time.Time
var endDates []time.Time
for d := weekEvent.Start; d.Before(weekEvent.End); d = d.AddDate(0, 0, 1) {
if d.Weekday() == weekEvent.Day {
startDates = append(startDates, time.Date(d.Year(), d.Month(), d.Day(), weekEvent.Start.Hour(), weekEvent.Start.Minute(), 0, 0, d.Location()))
endDates = append(endDates, time.Date(d.Year(), d.Month(), d.Day(), weekEvent.End.Hour(), weekEvent.End.Minute(), 0, 0, d.Location()))
}
}
return startDates, endDates
}
func splitByCommaWithTime(input string) []string {
var result []string
// Split by comma
parts := strings.Split(input, ", ")
// Regular expression to match a day with time
regex := regexp.MustCompile(`([A-Za-z]{2,}(\+[A-Za-z]{2,})* \d{2}:\d{2}-\d{2}:\d{2})`)
// Iterate over parts and combine when necessary
var currentPart string
for _, part := range parts {
if regex.MatchString(part) {
if currentPart != "" {
currentPart += ", " + part
result = append(result, currentPart)
currentPart = ""
} else {
result = append(result, part)
}
// If the part contains a day with time, start a new currentPart
} else {
// If there's no currentPart, start a new one
if currentPart != "" {
currentPart += ", " + part
} else {
currentPart = part
}
}
}
// Add the last currentPart to the result
if currentPart != "" {
result = append(result, currentPart)
}
return result
}
// check if ws or ss
func checkSemester(date time.Time) string {
if date.Month() >= 4 && date.Month() <= 9 {
return "ss"
} else {
return "ws"
}
}
// fetch the main page where all sport courses are listed and extract all links to the sport courses
func fetchAllAvailableSportCourses() ([]string, error) {
var url = "https://sport.htwk-leipzig.de/sportangebote"
var doc, err = htmlRequest(url)
if err != nil {
slog.Error("Error while fetching sport courses from webpage", "error", err)
return nil, err
}
// link list of all sport courses
var links []string
// find all links to sport courses with regex https://sport.htwk-leipzig.de/sportangebote/detail/sport/ + [0-9]{1,4}
doc.Find("a[href]").Each(func(i int, s *goquery.Selection) {
link, _ := s.Attr("href")
if strings.HasPrefix(link, "/sportangebote/detail/sport/") {
links = append(links, link)
}
})
return links, nil
}
// fetchAllHTWKSportCourses fetches all sport courses from the given links.
// to speed up the process, it uses multithreading.
func fetchHTWKSportCourses(links []string) []model.SportEntry {
//multithreaded webpage requests to speed up the process
var maxThreads = 10
var htmlPageArray = make([]*goquery.Document, len(links))
var hostUrl = "https://sport.htwk-leipzig.de"
var wg sync.WaitGroup
wg.Add(maxThreads)
for i := 0; i < maxThreads; i++ {
go func(i int) {
for j := i; j < len(links); j += maxThreads {
doc, err := htmlRequest(hostUrl + links[j])
if err == nil {
htmlPageArray[j] = doc
}
}
wg.Done()
}(i)
}
wg.Wait()
var events []model.SportEntry
for _, doc := range htmlPageArray {
if doc != nil {
event, err := fetchHtwkSportCourse(doc)
if err == nil {
events = append(events, event...)
}
}
}
return events
}
func htmlRequest(url string) (*goquery.Document, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer func(Body io.ReadCloser) {
readErr := Body.Close()
if readErr != nil {
slog.Error("Error while closing response body from html request", "error", readErr)
return
}
}(resp.Body)
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
return doc, nil
}
// fetchHtwkSportCourse fetches the sport course from the given url and id.
// If the sport course does not exist, it will return an error.
// If the sport course exists, it will return the sport course.
// goquery is used to parse the html. The html structure is not very consistent, so it is hard to parse.
// May be improved in the future.
func fetchHtwkSportCourse(doc *goquery.Document) ([]model.SportEntry, error) {
var events []model.SportEntry
germanTime, _ := time.LoadLocation("Europe/Berlin")
if doc.Find("h1").Text() == "Aktuelle Sportangebote" {
return nil, errors.New("not a sport course page")
}
doc.Find(".eventHead").Each(func(i int, s *goquery.Selection) {
var event model.SportEntry
var details model.EventDetails
fullTitle := strings.TrimSpace(s.Find("h3").Text())
titleParts := strings.Split(fullTitle, "-")
if len(titleParts) > 0 {
event.Title = strings.TrimSpace(titleParts[0])
}
if len(titleParts) > 2 {
details.Type = strings.TrimSpace(titleParts[len(titleParts)-1])
}
event.ID = parseEventID(fullTitle)
s.NextFiltered("table.eventDetails").Find("tr").Each(func(i int, s *goquery.Selection) {
key := strings.TrimSpace(s.Find("td").First().Text())
value := strings.TrimSpace(s.Find("td").Last().Text())
switch key {
case "Zeitraum":
dates := strings.Split(value, "-")
if len(dates) == 2 {
startDate, _ := time.ParseInLocation("02.01.2006", strings.TrimSpace(dates[0]), germanTime)
endDate, _ := time.ParseInLocation("02.01.2006", strings.TrimSpace(dates[1]), germanTime)
details.DateRange = model.DateRange{Start: startDate, End: endDate}
}
case "Zyklus":
details.Cycle = value
case "Geschlecht":
details.Gender = value
case "Leiter":
leaderName := strings.TrimSpace(s.Find("td a").Text())
leadersSlice := strings.Split(leaderName, "\n")
for i, leader := range leadersSlice {
leadersSlice[i] = strings.TrimSpace(leader)
}
formattedLeaders := strings.Join(leadersSlice, ", ")
leaderLink, _ := s.Find("td a").Attr("href")
details.CourseLead = model.CourseLead{Name: formattedLeaders, Link: leaderLink}
case "Ort":
locationDetails := strings.Split(value, "(")
if len(locationDetails) == 2 {
details.Location = model.Location{
Name: strings.TrimSpace(locationDetails[0]),
Address: strings.TrimRight(strings.TrimSpace(locationDetails[1]), ")"),
}
}
case "Teilnehmer":
parts := strings.Split(value, "/")
if len(parts) >= 3 {
bookings, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
totalPlaces, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
waitList, _ := strconv.Atoi(strings.TrimSpace(parts[2]))
details.Participants = model.Participants{Bookings: bookings, TotalPlaces: totalPlaces, WaitList: waitList}
}
case "Kosten":
details.Cost = value // makes no sense since you need to be logged in to see the price
case "Hinweis":
var allNotes []string
s.Find("td").Last().Contents().Each(func(i int, s *goquery.Selection) {
if s.Is("h4.eventAdvice") || goquery.NodeName(s) == "#text" {
note := strings.TrimSpace(s.Text())
if note != "" {
allNotes = append(allNotes, note)
}
}
})
event.AdditionalNote = strings.Join(allNotes, " ")
}
})
event.Details = details
events = append(events, event)
})
return events, nil
}
// parseEventID from fulltitle
// the event id is a number in the fulltitle thats not a time like HH:MM and shoudl be found after Nr. or Nr:
func parseEventID(fulltitle string) string {
var eventID string
var numberRegExp = regexp.MustCompile("[0-9]{1,4}")
var fulltitleParts = strings.Split(fulltitle, " ")
for i, part := range fulltitleParts {
if part == "Nr." || part == "Nr:" {
eventID = fulltitleParts[i+1]
break
}
}
if eventID == "" {
eventID = numberRegExp.FindString(fulltitle)
}
return eventID
}

View File

@@ -0,0 +1,56 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package sport
import (
"reflect"
"testing"
)
func TestSplitByCommaWithTime(t *testing.T) {
type args struct {
input string
}
tests := []struct {
name string
args args
want []string
}{
{"one string", args{"one"}, []string{"one"}},
{"two strings", args{"one,two"}, []string{"one,two"}},
{"three strings", args{"one,two,three"}, []string{"one,two,three"}},
// e.g. "Mo 18:00-20:00, Di 18:00-20:00" -> ["Mo 18:00-20:00", "Di 18:00-20:00"]
// e.g. "Mo 18:00-20:00, Di 18:00-20:00, Mi 18:00-20:00" -> ["Mo 18:00-20:00", "Di 18:00-20:00", "Mi 18:00-20:00"]
// e.g. "Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00" -> ["Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00"]
// e.g. "Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00" -> ["Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"]
// e.g. "Mo+Mi+Fr 18:00-20:00, Sa 20:00-21:00" -> ["Mo+Mi+Fr 18:00-20:00", "Sa 20:00-21:00"]
// e.g. "Mo+Mi 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00" -> ["Mo+Mi 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"]
{"Mo 18:00-20:00, Di 18:00-20:00", args{"Mo 18:00-20:00, Di 18:00-20:00"}, []string{"Mo 18:00-20:00", "Di 18:00-20:00"}},
{"Mo 18:00-20:00, Di 18:00-20:00, Mi 18:00-20:00", args{"Mo 18:00-20:00, Di 18:00-20:00, Mi 18:00-20:00"}, []string{"Mo 18:00-20:00", "Di 18:00-20:00", "Mi 18:00-20:00"}},
{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00", args{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00"}, []string{"Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00"}},
{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00", args{"Mo, Mi, Fr 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00"}, []string{"Mo, Mi, Fr 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"}},
{"Mo+Mi+Fr 18:00-20:00, Sa 20:00-21:00", args{"Mo+Mi+Fr 18:00-20:00, Sa 20:00-21:00"}, []string{"Mo+Mi+Fr 18:00-20:00", "Sa 20:00-21:00"}},
{"Mo+Mi 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00", args{"Mo+Mi 18:00-20:00, Sa 20:00-21:00, So 20:00-21:00"}, []string{"Mo+Mi 18:00-20:00", "Sa 20:00-21:00", "So 20:00-21:00"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := splitByCommaWithTime(tt.args.input); !reflect.DeepEqual(got, tt.want) {
t.Errorf("splitByCommaWithTime() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,304 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"fmt"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/net/html"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/date"
"htwkalender/data-manager/service/fetch"
"htwkalender/data-manager/service/functions"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
)
func ReplaceEmptyEventNames(group model.SeminarGroup) model.SeminarGroup {
for j, event := range group.Events {
if functions.OnlyWhitespace(event.Name) {
group.Events[j].Name = "Sonderveranstaltungen"
}
}
return group
}
func fetchHTMLFromURL(semester, seminarGroupLabel string) (string, error) {
// check that semester and seminarGroupLabel are not empty
if semester == "" || seminarGroupLabel == "" {
return "", fmt.Errorf("semester or seminarGroupLabel is empty")
}
url := "https://stundenplan.htwk-leipzig.de/" + semester + "/Berichte/Text-Listen;Studenten-Sets;name;" + seminarGroupLabel + "?template=sws_semgrp&weeks=1-65"
result, err := fetch.GetHTML(url)
if err != nil {
slog.Error("Error occurred while fetching the HTML document:", "error", err)
return "", err
}
return result, nil
}
func isSummerSemester(month time.Month) bool {
return month >= 3 && month <= 10
}
func isWinterSemester(month time.Month) bool {
return month >= 9 || month <= 4
}
func FetchAndParse(season, label string) (model.SeminarGroup, error) {
result, err := fetchHTMLFromURL(season, label)
if err != nil {
return model.SeminarGroup{}, err
}
return parseSeminarGroup(result), nil
}
func SplitEventType(events []model.Event) ([]model.Event, error) {
re, err := regexp.Compile("^([VPS])([wp])$")
if err != nil {
return nil, err
}
for i, event := range events {
matched := re.Match([]byte(event.EventType))
if matched {
eventType := event.EventType
event.EventType = eventType[0:1]
event.Compulsory = eventType[1:2]
events[i] = event
}
}
return events, nil
}
func parseSeminarGroup(result string) model.SeminarGroup {
doc, err := html.Parse(strings.NewReader(result))
if err != nil {
fmt.Printf("Error occurred while parsing the HTML document: %s\n", err.Error())
return model.SeminarGroup{}
}
table := findFirstTable(doc)
eventTables := getEventTables(doc)
allDayLabels := getAllDayLabels(doc)
course := findFirstSpanWithClass(table, "header-2-0-1").FirstChild.Data
semesterString := findFirstSpanWithClass(table, "header-0-2-0").FirstChild.Data
semester, year := extractSemesterAndYear(semesterString)
if eventTables == nil || allDayLabels == nil {
return model.SeminarGroup{
University: findFirstSpanWithClass(table, "header-1-0-0").FirstChild.Data,
Course: course,
Events: []model.Event{},
}
}
eventsWithCombinedWeeks := toEvents(eventTables, allDayLabels, course)
splitEventsByWeekVal := splitEventsByWeek(eventsWithCombinedWeeks)
events := splitEventsBySingleWeek(splitEventsByWeekVal)
events = convertWeeksToDates(events, semester, year)
events = generateUUIDs(events, course)
events, err = SplitEventType(events)
if err != nil {
slog.Error("Error occurred while splitting event types:", "error", err)
return model.SeminarGroup{}
}
var seminarGroup = model.SeminarGroup{
University: findFirstSpanWithClass(table, "header-1-0-0").FirstChild.Data,
Course: course,
Events: events,
}
return seminarGroup
}
func generateUUIDs(events []model.Event, course string) []model.Event {
for i, event := range events {
// generate a hash value from the event name, course and semester
hash := uuid.NewSHA1(uuid.NameSpaceOID, []byte(event.Name+course))
events[i].UUID = hash.String()
}
return events
}
// convertWeeksToDates converts the week and year to a date
// The date is calculated based on the week and the year
// The time is unset and 23:00 is used as default
// Additionally the semester is added to the event
func convertWeeksToDates(events []model.Event, semester string, year string) []model.Event {
var newEvents []model.Event
eventYear, _ := strconv.Atoi(year)
// for each event we need to calculate the start and end date based on the week and the year
for _, event := range events {
eventWeek, _ := strconv.Atoi(event.Week)
eventDay, _ := date.GetDateFromWeekNumber(eventYear, eventWeek, event.Day)
start := replaceTimeForDate(eventDay, event.Start.Time())
end := replaceTimeForDate(eventDay, event.End.Time())
//Check if end is before start
if end.Before(start) {
end = end.AddDate(0, 0, 1)
}
newEvent := event
newEvent.Start, _ = types.ParseDateTime(start.In(time.UTC))
newEvent.End, _ = types.ParseDateTime(end.In(time.UTC))
newEvent.Semester = semester
newEvents = append(newEvents, newEvent)
}
return newEvents
}
// replaceTimeForDate replaces hour, minute, second, nsec for the selected date
func replaceTimeForDate(date time.Time, replacementTime time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), replacementTime.Hour(), replacementTime.Minute(), replacementTime.Second(), replacementTime.Nanosecond(), date.Location())
}
func extractSemesterAndYear(semesterString string) (string, string) {
winterPattern := "Wintersemester"
summerPattern := "Sommersemester"
winterMatch := strings.Contains(semesterString, winterPattern)
summerMatch := strings.Contains(semesterString, summerPattern)
semester := ""
semesterShortcut := ""
if winterMatch {
semester = "Wintersemester"
semesterShortcut = "ws"
} else if summerMatch {
semester = "Sommersemester"
semesterShortcut = "ss"
} else {
return "", ""
}
yearPattern := `\d{4}`
combinedPattern := semester + `\s` + yearPattern
re := regexp.MustCompile(combinedPattern)
match := re.FindString(semesterString)
year := ""
if match != "" {
reYear := regexp.MustCompile(yearPattern)
year = reYear.FindString(match)
}
return semesterShortcut, year
}
func toEvents(tables [][]*html.Node, days []string, course string) []model.Event {
var events []model.Event
for table := range tables {
for row := range tables[table] {
tableData := findTableData(tables[table][row])
if len(tableData) > 0 {
start, _ := types.ParseDateTime(createTimeFromHourAndMinuteString(getTextContent(tableData[1])))
end, _ := types.ParseDateTime(createTimeFromHourAndMinuteString(getTextContent(tableData[2])))
events = append(events, model.Event{
Day: days[table],
Week: getTextContent(tableData[0]),
Start: start,
End: end,
Name: getTextContent(tableData[3]),
EventType: getTextContent(tableData[4]),
Prof: getTextContent(tableData[5]),
Rooms: getTextContent(tableData[6]),
Notes: getTextContent(tableData[7]),
BookedAt: getTextContent(tableData[8]),
Course: course,
})
}
}
}
return events
}
// createEventFromTableData should create an event from the table data
// tableTime represents Hour and Minute like HH:MM
// tableDate returns a Time
func createTimeFromHourAndMinuteString(tableTime string) time.Time {
timeParts := strings.Split(tableTime, ":")
hour, _ := strconv.Atoi(timeParts[0])
minute, _ := strconv.Atoi(timeParts[1])
return time.Date(0, 0, 0, hour, minute, 0, 0, time.UTC)
}
func splitEventsByWeek(events []model.Event) []model.Event {
var newEvents []model.Event
for _, event := range events {
weeks := strings.Split(event.Week, ",")
for _, week := range weeks {
newEvent := event
newEvent.Week = strings.TrimSpace(week)
newEvents = append(newEvents, newEvent)
}
}
return newEvents
}
func splitEventsBySingleWeek(events []model.Event) []model.Event {
var newEvents []model.Event
for _, event := range events {
if strings.Contains(event.Week, "-") {
weeks := splitWeekRange(event.Week)
for _, week := range weeks {
newEvent := event
newEvent.Week = week
newEvents = append(newEvents, newEvent)
}
} else {
newEvents = append(newEvents, event)
}
}
return newEvents
}
func splitWeekRange(weekRange string) []string {
parts := strings.Split(weekRange, "-")
if len(parts) != 2 {
return nil // Invalid format
}
start, errStart := strconv.Atoi(strings.TrimSpace(parts[0]))
end, errEnd := strconv.Atoi(strings.TrimSpace(parts[1]))
if errStart != nil || errEnd != nil {
return nil // Error converting to integers
}
var weeks []string
for i := start; i <= end; i++ {
weeks = append(weeks, strconv.Itoa(i))
}
return weeks
}

View File

@@ -0,0 +1,590 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"fmt"
"github.com/pocketbase/pocketbase/tools/types"
"htwkalender/data-manager/model"
"reflect"
"testing"
"time"
)
func TestExtractSemesterAndYear(t *testing.T) {
type args struct {
semesterString string
}
tests := []struct {
name string
args args
want string
want1 string
}{
{
name: "Test 1",
args: args{
semesterString: "Wintersemester 2023/24 (Planungszeitraum 01.09.2023 bis 03.03.2024)",
},
want: "ws",
want1: "2023",
},
{
name: "Test 2",
args: args{
semesterString: "Sommersemester 2023 (Planungszeitraum 06.03. bis 31.08.2023)",
},
want: "ss",
want1: "2023",
},
{
name: "Test 3",
args: args{
semesterString: "Sommersemester 2010 (Planungszeitraum 06.03. bis 31.08.2023)",
},
want: "ss",
want1: "2010",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1 := extractSemesterAndYear(tt.args.semesterString)
if got != tt.want {
t.Errorf("extractSemesterAndYear() got = %v, want %v", got, tt.want)
}
if got1 != tt.want1 {
t.Errorf("extractSemesterAndYear() got1 = %v, want %v", got1, tt.want1)
}
})
}
}
func TestReplaceEmptyEventNames(t *testing.T) {
type args struct {
group model.SeminarGroup
}
tests := []struct {
name string
args args
want model.SeminarGroup
}{
{
name: "Test 1",
args: args{
group: model.SeminarGroup{
Events: []model.Event{
{
Name: "Test",
},
},
},
},
want: model.SeminarGroup{
Events: []model.Event{
{
Name: "Test",
},
},
},
},
{
name: "Test 1",
args: args{
group: model.SeminarGroup{
Events: []model.Event{
{
Name: "",
},
},
},
},
want: model.SeminarGroup{
Events: []model.Event{
{
Name: "Sonderveranstaltungen",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ReplaceEmptyEventNames(tt.args.group); !reflect.DeepEqual(got, tt.want) {
t.Errorf("ReplaceEmptyEventNames() = %v, want %v", got, tt.want)
}
})
}
}
func TestSplitEventType(t *testing.T) {
type args struct {
events []model.Event
}
tests := []struct {
name string
args args
want []model.Event
}{
{
name: "Test 1",
args: args{
events: []model.Event{
{
EventType: "V",
},
},
},
want: []model.Event{
{
EventType: "V",
Compulsory: "",
},
},
},
{
name: "Test 2",
args: args{
events: []model.Event{
{
EventType: "Vw",
},
},
},
want: []model.Event{
{
EventType: "V",
Compulsory: "w",
},
},
},
{
name: "Test 3",
args: args{
events: []model.Event{
{
EventType: "Sperr",
},
},
},
want: []model.Event{
{
EventType: "Sperr",
Compulsory: "",
},
},
},
{
name: "Test 4",
args: args{
events: []model.Event{
{
EventType: "Sperr",
},
{
EventType: "Vw",
},
},
},
want: []model.Event{
{
EventType: "Sperr",
Compulsory: "",
},
{
EventType: "V",
Compulsory: "w",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got, _ := SplitEventType(tt.args.events); !reflect.DeepEqual(got, tt.want) {
t.Errorf("SplitEventType() = %v, want %v", got, tt.want)
}
})
}
}
func TestGenerateUUIDs(t *testing.T) {
type args struct {
events []model.Event
course string
}
tests := []struct {
name string
args args
want []model.Event
}{
{
name: "Test 1",
args: args{
events: []model.Event{
{
Name: " Arbeitssicherheit / Rechtsformen von Unternehmen B435 SBB (wpf) & B348 BIB (pf) 5. FS",
},
},
course: "21BIB-2a",
},
want: []model.Event{
{
Name: " Arbeitssicherheit / Rechtsformen von Unternehmen B435 SBB (wpf) & B348 BIB (pf) 5. FS",
UUID: "3720afdc-10c7-5b72-9489-cffb70cb0c13",
},
},
},
{
name: "Test 2",
args: args{
events: []model.Event{
{
Name: " Arbeitssicherheit / Rechtsformen von Unternehmen B435 SBB (wpf) & B348 BIB (pf) 5. FS",
},
},
course: "21BIB-2b",
},
want: []model.Event{
{
Name: " Arbeitssicherheit / Rechtsformen von Unternehmen B435 SBB (wpf) & B348 BIB (pf) 5. FS",
UUID: "81083480-bcf1-5452-af84-bb27d79282d8",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := generateUUIDs(tt.args.events, tt.args.course); !reflect.DeepEqual(got, tt.want) {
t.Errorf("generateUUIDs() = %v, want %v", got, tt.want)
}
})
}
}
func TestCreateTimeFromHourAndMinuteString(t *testing.T) {
type args struct {
tableTime string
}
tests := []struct {
name string
args args
want time.Time
}{
{
name: "Test 1",
args: args{
tableTime: "08:00",
},
want: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC),
},
{
name: "Test 2",
args: args{
tableTime: "08:15",
},
want: time.Date(0, 0, 0, 8, 15, 0, 0, time.UTC),
},
{
name: "Test 3",
args: args{
tableTime: "08:30",
},
want: time.Date(0, 0, 0, 8, 30, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := createTimeFromHourAndMinuteString(tt.args.tableTime); !reflect.DeepEqual(got, tt.want) {
t.Errorf("createTimeFromHourAndMinuteString() = %v, want %v", got, tt.want)
}
})
}
}
func TestReplaceTimeInDate(t *testing.T) {
type args struct {
date time.Time
time time.Time
}
tests := []struct {
name string
args args
want time.Time
}{
{
name: "Test 1",
args: args{
date: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
time: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC),
},
want: time.Date(2021, 1, 1, 8, 0, 0, 0, time.UTC),
},
{
name: "Test 2",
args: args{
date: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
time: time.Date(0, 0, 0, 8, 15, 0, 0, time.UTC),
},
want: time.Date(2021, 1, 1, 8, 15, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := replaceTimeForDate(tt.args.date, tt.args.time); !reflect.DeepEqual(got, tt.want) {
t.Errorf("addTimeToDate() = %v, want %v", got, tt.want)
}
})
}
}
func TestConvertWeeksToDates(t *testing.T) {
type args struct {
events []model.Event
semester string
year string
}
returnDateTime := func(date time.Time) types.DateTime {
dateTime, err := types.ParseDateTime(date)
if err != nil {
fmt.Println(err)
}
return dateTime
}
tests := []struct {
name string
args args
want []model.Event
}{
{
name: "Test Wintertime",
args: args{
events: []model.Event{
{
Week: "1",
Day: "Montag",
Start: returnDateTime(time.Date(0, 0, 0, 7, 30, 0, 0, time.UTC)),
End: returnDateTime(time.Date(0, 0, 0, 9, 0, 0, 0, time.UTC)),
},
},
semester: "ws",
year: "2021",
},
want: []model.Event{
{
Week: "1",
Day: "Montag",
Start: returnDateTime(time.Date(2021, 1, 4, 6, 30, 0, 0, time.UTC)),
End: returnDateTime(time.Date(2021, 1, 4, 8, 0, 0, 0, time.UTC)),
Semester: "ws",
},
},
},
{
name: "Test Summertime",
args: args{
events: []model.Event{
{
Week: "30",
Day: "Donnerstag",
Start: returnDateTime(time.Date(0, 0, 0, 7, 30, 0, 0, time.UTC)),
End: returnDateTime(time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)),
},
},
semester: "ws",
year: "2023",
},
want: []model.Event{
{
Week: "30",
Day: "Donnerstag",
Start: returnDateTime(time.Date(2023, 7, 27, 5, 30, 0, 0, time.UTC)),
End: returnDateTime(time.Date(2023, 7, 27, 22, 0, 0, 0, time.UTC)),
Semester: "ws",
},
},
},
{
name: "Test NextDay",
args: args{
events: []model.Event{
{
Week: "45",
Day: "Donnerstag",
Start: returnDateTime(time.Date(0, 0, 0, 7, 30, 0, 0, time.UTC)),
End: returnDateTime(time.Date(0, 0, 0, 4, 0, 0, 0, time.UTC)),
},
},
semester: "ws",
year: "2023",
},
want: []model.Event{
{
Week: "45",
Day: "Donnerstag",
Start: returnDateTime(time.Date(2023, 11, 9, 6, 30, 0, 0, time.UTC)),
End: returnDateTime(time.Date(2023, 11, 10, 3, 0, 0, 0, time.UTC)),
Semester: "ws",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := convertWeeksToDates(tt.args.events, tt.args.semester, tt.args.year); !reflect.DeepEqual(got, tt.want) {
t.Errorf("convertWeeksToDates() = %v, want %v", got, tt.want)
}
})
}
}
func TestReplaceTimeForDate(t *testing.T) {
type args struct {
date time.Time
replacementTime time.Time
}
tests := []struct {
name string
args args
want time.Time
}{
{
name: "Replace Hour",
args: args{
date: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
replacementTime: time.Date(0, 0, 0, 8, 0, 0, 0, time.UTC),
},
want: time.Date(2021, 1, 1, 8, 0, 0, 0, time.UTC),
},
{
name: "Replace Hour and Minute",
args: args{
date: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
replacementTime: time.Date(0, 0, 0, 8, 15, 0, 0, time.UTC),
},
want: time.Date(2021, 1, 1, 8, 15, 0, 0, time.UTC),
},
{
name: "Replace Hour and Minute",
args: args{
date: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
replacementTime: time.Date(0, 0, 0, 8, 30, 0, 0, time.UTC),
},
want: time.Date(2021, 1, 1, 8, 30, 0, 0, time.UTC),
},
{
name: "Replace Hour and Minute without Year, Month, Day",
args: args{
date: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC),
replacementTime: time.Date(2023, 10, 3, 8, 30, 0, 0, time.UTC),
},
want: time.Date(2021, 1, 1, 8, 30, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := replaceTimeForDate(tt.args.date, tt.args.replacementTime); !reflect.DeepEqual(got, tt.want) {
t.Errorf("replaceTimeForDate() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsSummerSemester(t *testing.T) {
type args struct {
month time.Month
}
tests := []struct {
name string
args args
want bool
}{
{
name: "Test Summer March",
args: args{
month: time.March,
},
want: true,
},
{
name: "Test Summer September",
args: args{
month: time.September,
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSummerSemester(tt.args.month); got != tt.want {
t.Errorf("isSummerSemester() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsWinterSemester(t *testing.T) {
type args struct {
month time.Month
}
tests := []struct {
name string
args args
want bool
}{
{
name: "Test Winter March",
args: args{
month: time.March,
},
want: true,
},
{
name: "Test Winter September",
args: args{
month: time.September,
},
want: true,
},
{
name: "Test Winter November",
args: args{
month: time.November,
},
want: true,
},
{
name: "Test Winter February",
args: args{
month: time.February,
},
want: true,
},
{
name: "Test Winter June",
args: args{
month: time.June,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isWinterSemester(tt.args.month); got != tt.want {
t.Errorf("isWinterSemester() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,138 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"encoding/xml"
"fmt"
"github.com/pocketbase/pocketbase"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
"htwkalender/data-manager/service/functions"
"htwkalender/data-manager/service/functions/time"
"io"
"log/slog"
"net/http"
)
func getSeminarHTML(semester string) (string, error) {
url := "https://stundenplan.htwk-leipzig.de/stundenplan/xml/public/semgrp_" + semester + ".xml"
// Send GET request
response, err := http.Get(url)
if err != nil {
fmt.Printf("Error occurred while making the request: %s\n", err.Error())
return "", err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
return
}
}(response.Body)
// Read the response body
body, err := io.ReadAll(response.Body)
if err != nil {
fmt.Printf("Error occurred while reading the response: %s\n", err.Error())
return "", err
}
return string(body), err
}
func FetchSeminarGroups(app *pocketbase.PocketBase) (db.SeminarGroups, error) {
var groups db.SeminarGroups
semesterString := functions.CalculateSemesterList(time.RealClock{})
var results [2]string
var err error
for i, semester := range semesterString {
results[i], err = getSeminarHTML(semester)
if err != nil {
slog.Error("Error while fetching seminar groups for: "+semester, "error", err)
return nil, err
}
groups = append(groups, parseSeminarGroups(results[i], semester)...)
}
// filter duplicates
groups = removeDuplicates(groups)
insertedGroups, dbError := db.SaveGroups(groups, app)
if dbError != nil {
slog.Error("FetchSeminarGroups", "error", dbError)
return nil, err
}
return insertedGroups, nil
}
func removeDuplicates(groups db.SeminarGroups) db.SeminarGroups {
uniqueGroups := make(db.SeminarGroups, 0, len(groups))
seen := make(map[string]struct{}) // Use an empty struct to minimize memory usage
// unique Identifier is the course and semester
for _, group := range groups {
key := group.UniqueKey()
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
uniqueGroups = append(uniqueGroups, group)
}
}
return uniqueGroups
}
func contains(groups []model.SeminarGroup, group model.SeminarGroup) bool {
for _, a := range groups {
if (a.Course == group.Course) && (a.Semester == group.Semester) {
return true
}
}
return false
}
func parseSeminarGroups(result string, semester string) db.SeminarGroups {
var studium model.Studium
err := xml.Unmarshal([]byte(result), &studium)
if err != nil {
return nil
}
var seminarGroups db.SeminarGroups
for _, faculty := range studium.Faculty {
for _, Studiengang := range faculty.Studiengang {
for _, Studienrichtung := range Studiengang.Semgrp {
seminarGroup := db.SeminarGroup{
University: "HTWK-Leipzig",
GroupShortcut: Studiengang.Name,
GroupId: Studiengang.ID,
Course: Studienrichtung.Name,
Faculty: faculty.Name,
FacultyId: faculty.ID,
Semester: semester,
}
seminarGroups = append(seminarGroups, &seminarGroup)
}
}
}
return seminarGroups
}

View File

@@ -0,0 +1,91 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"htwkalender/data-manager/model"
"testing"
)
func TestContains(t *testing.T) {
type args struct {
groups []model.SeminarGroup
group model.SeminarGroup
}
tests := []struct {
name string
args args
want bool
}{
{
name: "should return true if group is in groups",
args: args{
groups: []model.SeminarGroup{
{
Course: "test",
Semester: "test",
},
},
group: model.SeminarGroup{
Course: "test",
Semester: "test",
},
},
want: true,
},
{
name: "should return false if group is not in groups",
args: args{
groups: []model.SeminarGroup{
{
Course: "test",
Semester: "test",
},
},
group: model.SeminarGroup{
Course: "test",
Semester: "test2",
},
},
want: false,
},
{
name: "should return false if group is not in courses",
args: args{
groups: []model.SeminarGroup{
{
Course: "test3",
Semester: "test",
},
},
group: model.SeminarGroup{
Course: "test",
Semester: "test",
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := contains(tt.args.groups, tt.args.group); got != tt.want {
t.Errorf("contains() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,217 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"golang.org/x/net/html"
"strings"
)
// Find the first <table> element in the HTML document
func findFirstTable(node *html.Node) *html.Node {
if node.Type == html.ElementNode && node.Data == "table" {
return node
}
// Traverse child nodes recursively
for child := node.FirstChild; child != nil; child = child.NextSibling {
found := findFirstTable(child)
if found != nil {
return found
}
}
return nil
}
// Find the first <span> element with the specified class attribute value
func findFirstSpanWithClass(node *html.Node, classValue string) *html.Node {
// Check if the current node is a <span> element with the specified class attribute value
if node.Type == html.ElementNode && node.Data == "span" {
if hasClassAttribute(node, classValue) {
return node
}
}
// Traverse child nodes recursively
for child := node.FirstChild; child != nil; child = child.NextSibling {
found := findFirstSpanWithClass(child, classValue)
if found != nil {
return found
}
}
return nil
}
// Check if the specified element has the specified class attribute value
func hasClassAttribute(node *html.Node, classValue string) bool {
for _, attr := range node.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, classValue) {
return true
}
}
return false
}
// Get Tables with days
func getEventTables(node *html.Node) [][]*html.Node {
var eventTables [][]*html.Node
tables := findTables(node)
// get all tables with events
for events := range tables {
rows := findTableRows(tables[events])
// check that a first row exists
if len(rows) > 0 {
rows = rows[1:]
eventTables = append(eventTables, rows)
}
}
return eventTables
}
// Get Tables with days
func getAllDayLabels(node *html.Node) []string {
paragraphs := findParagraphs(node)
var dayArray []string
for _, p := range paragraphs {
label := getDayLabel(p)
if label != "" {
dayArray = append(dayArray, label)
}
}
return dayArray
}
// Find all <p> elements in the HTML document
func findParagraphs(node *html.Node) []*html.Node {
var paragraphs []*html.Node
if node.Type == html.ElementNode && node.Data == "p" {
paragraphs = append(paragraphs, node)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
paragraphs = append(paragraphs, findParagraphs(child)...)
}
return paragraphs
}
// Find all <tr> elements in <tbody>, excluding the first one
func findTableRows(node *html.Node) []*html.Node {
var tableRows []*html.Node
if node.Type == html.ElementNode && node.Data == "tbody" {
child := node.FirstChild
for child != nil {
if child.Type == html.ElementNode && child.Data == "tr" {
tableRows = append(tableRows, child)
}
child = child.NextSibling
}
}
// Traverse child nodes recursively
for child := node.FirstChild; child != nil; child = child.NextSibling {
var tableRowElement = findTableRows(child)
if tableRowElement != nil {
tableRows = append(tableRows, tableRowElement...)
}
}
// check if tableRows is nil
if tableRows == nil {
return []*html.Node{}
} else {
return tableRows
}
}
// Find all <p> elements in the HTML document
func findTables(node *html.Node) []*html.Node {
var tables []*html.Node
if node.Type == html.ElementNode && node.Data == "table" {
tables = append(tables, node)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
tables = append(tables, findDayTables(child)...)
}
return tables
}
// Find all <p> elements in the HTML document
func findDayTables(node *html.Node) []*html.Node {
var tables []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
tables = append(tables, findDayTables(child)...)
}
if node.Type == html.ElementNode && node.Data == "table" && hasClassAttribute(node, "spreadsheet") {
tables = append(tables, node)
}
return tables
}
// Get the text content of the specified node and its descendants
func getDayLabel(node *html.Node) string {
child := node.FirstChild
if child != nil {
if child.Type == html.ElementNode && child.Data == "span" {
if child.FirstChild != nil {
return child.FirstChild.Data
}
}
}
return ""
}
// Find all <td> elements in the current <tr>
func findTableData(node *html.Node) []*html.Node {
var tableData []*html.Node
if node.Type == html.ElementNode && node.Data == "tr" {
child := node.FirstChild
for child != nil {
if child.Type == html.ElementNode && child.Data == "td" {
tableData = append(tableData, child)
}
child = child.NextSibling
}
}
return tableData
}
// Get the text content of the specified node and its descendants
func getTextContent(node *html.Node) string {
var textContent string
if node.Type == html.TextNode {
textContent = node.Data
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
textContent += getTextContent(child)
}
return textContent
}

View File

@@ -0,0 +1,75 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v2
import (
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/net/html"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/date"
"htwkalender/data-manager/service/functions"
"strings"
)
func toEvents(tables [][]*html.Node, days []string) []model.Event {
var events []model.Event
for tableIndex, table := range tables {
day := days[tableIndex]
for _, row := range table {
tableData := findTableData(row)
if len(tableData) == 0 {
continue
}
events = append(events, createEventsFromTableData(tableData, day)...)
}
}
return events
}
func createEventsFromTableData(tableData []*html.Node, day string) []model.Event {
var events []model.Event
start, _ := types.ParseDateTime(date.CreateTimeFromHourAndMinuteString(getTextContent(tableData[1])))
end, _ := types.ParseDateTime(date.CreateTimeFromHourAndMinuteString(getTextContent(tableData[2])))
name := getTextContent(tableData[3])
if functions.OnlyWhitespace(name) {
name = "Sonderveranstaltung"
}
courses := getTextContent(tableData[7])
if len(courses) > 0 {
for _, course := range strings.Split(courses, " ") {
events = append(events, model.Event{
Day: day,
Week: getTextContent(tableData[0]),
Start: start,
End: end,
Name: name,
EventType: getTextContent(tableData[4]),
Notes: getTextContent(tableData[5]),
Prof: getTextContent(tableData[6]),
Rooms: getTextContent(tableData[8]),
BookedAt: getTextContent(tableData[10]),
Course: strings.TrimSpace(course),
})
}
}
return events
}

View File

@@ -0,0 +1,207 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v2
import (
"fmt"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/daos"
"golang.org/x/net/html"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
"htwkalender/data-manager/service/fetch"
v1 "htwkalender/data-manager/service/fetch/v1"
"htwkalender/data-manager/service/functions"
localTime "htwkalender/data-manager/service/functions/time"
"log/slog"
"strings"
)
func ParseEventsFromRemote(app *pocketbase.PocketBase) (model.Events, error) {
savedRecords, err := FetchAllEventsAndSave(app, localTime.RealClock{})
if err != nil {
return nil, err
}
return savedRecords, nil
}
func FetchAllEventsAndSave(app *pocketbase.PocketBase, clock localTime.Clock) ([]model.Event, error) {
var savedRecords []model.Event
var err error = nil
var stubUrl = [2]string{
"https://stundenplan.htwk-leipzig.de/",
"/Berichte/Text-Listen;Veranstaltungsarten;name;" +
"Vp%0A" +
"Vw%0A" +
"V%0A" +
"Sp%0A" +
"Sw%0A" +
"S%0A" +
"Pp%0A" +
"Pw%0A" +
"P%0A" +
"ZV%0A" +
"Tut%0A" +
"Sperr%0A" +
"pf%0A" +
"wpf%0A" +
"fak%0A" +
"Pruefung%0A" +
"gebucht%0A" +
"Vertretung%0A" +
"Fremdveranst.%0A" +
"Buchen%0A" +
"%0A?&template=sws_modul&weeks=1-65&combined=yes",
}
// Fetch and save events for all semesters
for _, semester := range functions.CalculateSemesterList(clock) {
events, fetchErr := fetchAndSaveAllEventsForSemester(app, semester, stubUrl)
if fetchErr != nil {
return nil, fmt.Errorf("failed to fetch and save events for "+semester+": %w", "error", err)
}
savedRecords = append(savedRecords, events...)
}
return savedRecords, err
}
func fetchAndSaveAllEventsForSemester(
app *pocketbase.PocketBase,
semester string,
stubUrl [2]string,
) ([]model.Event, error) {
var savedRecords []model.Event
url := stubUrl[0] + semester + stubUrl[1]
events, err := parseEventForOneSemester(url)
if err != nil {
return nil, fmt.Errorf("failed to parse events for "+semester+": %w", "error", err)
}
err = updateDatabase(app, events, "Sport", semester)
return savedRecords, err
}
func updateDatabase(app *pocketbase.PocketBase, eventsToBeAdded []model.Event, course string, semester string) error {
var addedEvents []model.Event
var err error
// to in transaction the events will be added and deleted
err = app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
err = db.DeleteAllEventsRatherThenCourse(txDao, course, semester)
if err != nil {
return err
}
addedEvents, err = db.SaveEvents(eventsToBeAdded, txDao)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
slog.Info("Added events: ", "events", len(addedEvents))
return nil
}
func parseEventForOneSemester(url string) ([]model.Event, error) {
// Fetch Webpage from URL
webpage, err := fetch.GetHTML(url)
if err != nil {
return nil, err
}
// Parse HTML to Node Tree
var doc *html.Node
doc, err = parseHTML(webpage)
if err != nil {
return nil, err
}
// Get all event tables and all day labels
eventTables := getEventTables(doc)
allDayLabels := getAllDayLabels(doc)
eventsWithCombinedWeeks := toEvents(eventTables, allDayLabels)
splitEventsByWeekVal := splitEventsByWeek(eventsWithCombinedWeeks)
events := splitEventsBySingleWeek(splitEventsByWeekVal)
if events == nil {
return nil, err
}
table := findFirstTable(doc)
if table == nil {
return nil, fmt.Errorf("failed to find first table")
}
semesterString := findFirstSpanWithClass(table, "header-0-2-0").FirstChild.Data
semester, year := extractSemesterAndYear(semesterString)
events = convertWeeksToDates(events, semester, year)
events, err = v1.SplitEventType(events)
if err != nil {
slog.Error("Error occurred while splitting event types: ", "error", err)
return nil, err
}
events = switchNameAndNotesForExam(events)
events = generateUUIDs(events)
return events, nil
}
// switch name and notes for Pruefung events when Note is not empty and Name starts with "Prüfungen" and contains email
func switchNameAndNotesForExam(events []model.Event) []model.Event {
for i, event := range events {
if event.EventType == "Pruefung" {
if event.Notes != "" && strings.HasPrefix(event.Name, "Prüfungen") && strings.Contains(event.Name, "@") {
events[i].Name = event.Notes
events[i].Notes = event.Name
}
}
}
return events
}
func parseHTML(webpage string) (*html.Node, error) {
doc, err := html.Parse(strings.NewReader(webpage))
if err != nil {
return nil, err
}
return doc, nil
}
// generateUUIDs generates a UUID for each event based on the event name, course and semester
// the UUID is used to identify the event in the database
func generateUUIDs(events []model.Event) []model.Event {
for i, event := range events {
// generate a hash value from the event name, course and semester
hash := uuid.NewSHA1(uuid.NameSpaceOID, []byte(event.Name+event.Course))
events[i].UUID = hash.String()
}
return events
}

View File

@@ -0,0 +1,99 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v2
import (
"htwkalender/data-manager/model"
"reflect"
"testing"
)
func TestSwitchNameAndNotesForExam(t *testing.T) {
type args struct {
events []model.Event
}
tests := []struct {
name string
args args
want []model.Event
}{
{
name: "switch name and notes for exam",
args: args{
events: []model.Event{
{
EventType: "Pruefung",
Name: "Prüfungen FING/EIT WiSe (pruefungsamt.fing-eit@htwk-leipzig.de)",
Notes: "Computer Vision II - Räume/Zeit unter Vorbehalt- (Raum W111.1)",
},
},
},
want: []model.Event{
{
EventType: "Pruefung",
Name: "Computer Vision II - Räume/Zeit unter Vorbehalt- (Raum W111.1)",
Notes: "Prüfungen FING/EIT WiSe (pruefungsamt.fing-eit@htwk-leipzig.de)",
},
},
},
{
name: "dont switch name and notes for exam",
args: args{
events: []model.Event{
{
EventType: "Pruefung",
Name: "i054 Umweltschutz und Recycling DPB & VNB 7.FS (wpf)",
Notes: "Prüfung",
},
},
},
want: []model.Event{
{
EventType: "Pruefung",
Notes: "Prüfung",
Name: "i054 Umweltschutz und Recycling DPB & VNB 7.FS (wpf)",
},
},
},
{
name: "dont switch name and notes for exam",
args: args{
events: []model.Event{
{
EventType: "Pruefung",
Name: "Prüfungen FING/ME WiSe (pruefungsamt.fing-me@htwk-leipzig.de)",
Notes: "",
},
},
},
want: []model.Event{
{
EventType: "Pruefung",
Notes: "",
Name: "Prüfungen FING/ME WiSe (pruefungsamt.fing-me@htwk-leipzig.de)",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := switchNameAndNotesForExam(tt.args.events); !reflect.DeepEqual(got, tt.want) {
t.Errorf("switchNameAndNotesForExam() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,340 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package v2
import (
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/net/html"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/date"
"regexp"
"strconv"
"strings"
"time"
)
// Find the first <table> element in the HTML document
func findFirstTable(node *html.Node) *html.Node {
if node.Type == html.ElementNode && node.Data == "table" {
return node
}
// Traverse child nodes recursively
for child := node.FirstChild; child != nil; child = child.NextSibling {
found := findFirstTable(child)
if found != nil {
return found
}
}
return nil
}
// Find the first <span> element with the specified class attribute value
func findFirstSpanWithClass(node *html.Node, classValue string) *html.Node {
// Check if the current node is a <span> element with the specified class attribute value
if node.Type == html.ElementNode && node.Data == "span" {
if hasClassAttribute(node, classValue) {
return node
}
}
// Traverse child nodes recursively
for child := node.FirstChild; child != nil; child = child.NextSibling {
found := findFirstSpanWithClass(child, classValue)
if found != nil {
return found
}
}
return nil
}
// Check if the specified element has the specified class attribute value
func hasClassAttribute(node *html.Node, classValue string) bool {
for _, attr := range node.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, classValue) {
return true
}
}
return false
}
// Get Tables with days
func getEventTables(node *html.Node) [][]*html.Node {
var eventTables [][]*html.Node
tables := findTables(node)
// get all tables with events
for events := range tables {
rows := findTableRows(tables[events])
// check that a first row exists
if len(rows) > 0 {
rows = rows[1:]
eventTables = append(eventTables, rows)
}
}
return eventTables
}
// Get Tables with days
func getAllDayLabels(node *html.Node) []string {
paragraphs := findParagraphs(node)
var dayArray []string
for _, p := range paragraphs {
label := getDayLabel(p)
if label != "" {
dayArray = append(dayArray, label)
}
}
return dayArray
}
// Find all <p> elements in the HTML document
func findParagraphs(node *html.Node) []*html.Node {
var paragraphs []*html.Node
if node.Type == html.ElementNode && node.Data == "p" {
paragraphs = append(paragraphs, node)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
paragraphs = append(paragraphs, findParagraphs(child)...)
}
return paragraphs
}
// Find all <tr> elements in <tbody>, excluding the first one
func findTableRows(node *html.Node) []*html.Node {
var tableRows []*html.Node
if node.Type == html.ElementNode && node.Data == "tbody" {
child := node.FirstChild
for child != nil {
if child.Type == html.ElementNode && child.Data == "tr" {
tableRows = append(tableRows, child)
}
child = child.NextSibling
}
}
// Traverse child nodes recursively
for child := node.FirstChild; child != nil; child = child.NextSibling {
var tableRowElement = findTableRows(child)
if tableRowElement != nil {
tableRows = append(tableRows, tableRowElement...)
}
}
// check if tableRows is nil
if tableRows == nil {
return []*html.Node{}
} else {
return tableRows
}
}
// Find all <p> elements in the HTML document
func findTables(node *html.Node) []*html.Node {
var tables []*html.Node
if node.Type == html.ElementNode && node.Data == "table" {
tables = append(tables, node)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
tables = append(tables, findDayTables(child)...)
}
return tables
}
// Find all <p> elements in the HTML document
func findDayTables(node *html.Node) []*html.Node {
var tables []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
tables = append(tables, findDayTables(child)...)
}
if node.Type == html.ElementNode && node.Data == "table" && hasClassAttribute(node, "spreadsheet") {
tables = append(tables, node)
}
return tables
}
// Get the text content of the specified node and its descendants
func getDayLabel(node *html.Node) string {
child := node.FirstChild
if child != nil {
if child.Type == html.ElementNode && child.Data == "span" {
if child.FirstChild != nil {
return child.FirstChild.Data
}
}
}
return ""
}
// Find all <td> elements in the current <tr>
func findTableData(node *html.Node) []*html.Node {
var tableData []*html.Node
if node.Type == html.ElementNode && node.Data == "tr" {
child := node.FirstChild
for child != nil {
if child.Type == html.ElementNode && child.Data == "td" {
tableData = append(tableData, child)
}
child = child.NextSibling
}
}
return tableData
}
// Get the text content of the specified node and its descendants
func getTextContent(node *html.Node) string {
var textContent string
if node.Type == html.TextNode {
textContent = node.Data
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
textContent += getTextContent(child)
}
return textContent
}
func splitEventsByWeek(events []model.Event) []model.Event {
var newEvents []model.Event
for _, event := range events {
weeks := strings.Split(event.Week, ",")
for _, week := range weeks {
newEvent := event
newEvent.Week = strings.TrimSpace(week)
newEvents = append(newEvents, newEvent)
}
}
return newEvents
}
func splitEventsBySingleWeek(events []model.Event) []model.Event {
var newEvents []model.Event
for _, event := range events {
if strings.Contains(event.Week, "-") {
weeks := splitWeekRange(event.Week)
for _, week := range weeks {
newEvent := event
newEvent.Week = week
newEvents = append(newEvents, newEvent)
}
} else {
newEvents = append(newEvents, event)
}
}
return newEvents
}
func splitWeekRange(weekRange string) []string {
parts := strings.Split(weekRange, "-")
if len(parts) != 2 {
return nil // Invalid format
}
start, errStart := strconv.Atoi(strings.TrimSpace(parts[0]))
end, errEnd := strconv.Atoi(strings.TrimSpace(parts[1]))
if errStart != nil || errEnd != nil {
return nil // Error converting to integers
}
var weeks []string
for i := start; i <= end; i++ {
weeks = append(weeks, strconv.Itoa(i))
}
return weeks
}
func extractSemesterAndYear(semesterString string) (string, string) {
winterPattern := "Wintersemester"
summerPattern := "Sommersemester"
winterMatch := strings.Contains(semesterString, winterPattern)
summerMatch := strings.Contains(semesterString, summerPattern)
semester := ""
semesterShortcut := ""
if winterMatch {
semester = "Wintersemester"
semesterShortcut = "ws"
} else if summerMatch {
semester = "Sommersemester"
semesterShortcut = "ss"
} else {
return "", ""
}
yearPattern := `\d{4}`
combinedPattern := semester + `\s` + yearPattern
re := regexp.MustCompile(combinedPattern)
match := re.FindString(semesterString)
year := ""
if match != "" {
reYear := regexp.MustCompile(yearPattern)
year = reYear.FindString(match)
}
return semesterShortcut, year
}
func convertWeeksToDates(events []model.Event, semester string, year string) []model.Event {
var newEvents []model.Event
eventYear, _ := strconv.Atoi(year)
// for each event we need to calculate the start and end date based on the week and the year
for _, event := range events {
eventWeek, _ := strconv.Atoi(event.Week)
eventDay, _ := date.GetDateFromWeekNumber(eventYear, eventWeek, event.Day)
start := replaceTimeForDate(eventDay, event.Start.Time())
end := replaceTimeForDate(eventDay, event.End.Time())
//Check if end is before start
if end.Before(start) {
end = end.AddDate(0, 0, 1)
}
newEvent := event
newEvent.Start, _ = types.ParseDateTime(start.In(time.UTC))
newEvent.End, _ = types.ParseDateTime(end.In(time.UTC))
newEvent.Semester = semester
newEvents = append(newEvents, newEvent)
}
return newEvents
}
// replaceTimeForDate replaces hour, minute, second, nsec for the selected date
func replaceTimeForDate(date time.Time, replacementTime time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), replacementTime.Hour(), replacementTime.Minute(), replacementTime.Second(), replacementTime.Nanosecond(), date.Location())
}

View File

@@ -0,0 +1,33 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core";
const localeStore = defineStore("localeStore", {
state: () => {
return {
locale: useLocalStorage("locale", "de"), //useLocalStorage takes in a key of 'count' and default value of 0
};
},
actions: {
setLocale(locale: string) {
this.locale = locale;
},
},
});
export default localeStore;

View File

@@ -0,0 +1,47 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package functions
import (
localTime "htwkalender/data-manager/service/functions/time"
"time"
)
// GetCurrentSemesterString returns the current semester as string
// if current month is between 10 and 03 -> winter semester "ws"
func GetCurrentSemesterString(localeTime localTime.Clock) string {
if localeTime.Now().Month() >= 10 || localeTime.Now().Month() <= 3 {
return "ws"
} else {
return "ss"
}
}
func CalculateSemesterList(clock localTime.Clock) []string {
summerSemester := clock.Now().Month() >= time.March && clock.Now().Month() <= time.September
winterSemester := clock.Now().Month() <= time.March || clock.Now().Month() >= time.September
if summerSemester && !winterSemester {
return []string{"ss"}
}
if !summerSemester && winterSemester {
return []string{"ws"}
}
return []string{"ss", "ws"}
}

View File

@@ -0,0 +1,91 @@
package functions
import (
mockTime "htwkalender/data-manager/service/functions/time"
"reflect"
"testing"
"time"
)
func TestCalculateSemesterList(t *testing.T) {
type args struct {
clock mockTime.Clock
}
tests := []struct {
name string
args args
want []string
}{
{
name: "is summer semester",
args: args{
clock: mockTime.MockClock{
NowTime: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC),
},
},
want: []string{"ss"},
},
{
name: "is winter semester",
args: args{
clock: mockTime.MockClock{
NowTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
want: []string{"ws"},
},
{
name: "is in both",
args: args{
clock: mockTime.MockClock{
NowTime: time.Date(2024, 3, 22, 0, 0, 0, 0, time.UTC),
},
},
want: []string{"ss", "ws"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := CalculateSemesterList(tt.args.clock); !reflect.DeepEqual(got, tt.want) {
t.Errorf("calculateSemesterList() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetCurrentSemesterString(t *testing.T) {
type args struct {
localeTime mockTime.Clock
}
tests := []struct {
name string
args args
want string
}{
{
name: "is winter semester",
args: args{
localeTime: mockTime.MockClock{
NowTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
want: "ws",
},
{
name: "is summer semester",
args: args{
localeTime: mockTime.MockClock{
NowTime: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC),
},
},
want: "ss",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetCurrentSemesterString(tt.args.localeTime); got != tt.want {
t.Errorf("GetCurrentSemesterString() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,62 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package functions
import (
"crypto/sha256"
"encoding/hex"
"strings"
)
// check if string is empty or contains only whitespaces
func OnlyWhitespace(word string) bool {
return len(strings.TrimSpace(word)) == 0
}
// return function to check if rune is a separator
func IsSeparator(separator []rune) func(rune) bool {
return func(character rune) bool {
for _, sep := range separator {
if sep == character {
return true
}
}
return false
}
}
func Contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func HashString(s string) string {
hash := sha256.New()
hash.Write([]byte(s))
hashInBytes := hash.Sum(nil)
return hex.EncodeToString(hashInBytes)
}
func SeperateRoomString(rooms string) []string {
return strings.FieldsFunc(rooms, IsSeparator(
[]rune{',', '\t', '\n', '\r', ';', ' ', '\u00A0'}),
)
}

View File

@@ -0,0 +1,145 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package functions
import (
"reflect"
"testing"
)
func TestOnlyWhitespace(t *testing.T) {
type args struct {
word string
}
tests := []struct {
name string
args args
want bool
}{
{"empty string", args{""}, true},
{"whitespace", args{" "}, true},
{"whitespaces", args{" "}, true},
{"whitespaces and tabs", args{" \t"}, true},
{"whitespaces and tabs and newlines", args{" \t\n"}, true},
{"whitespaces and tabs and newlines and non-breaking spaces", args{" \t\n\u00A0"}, true},
{"non-whitespace", args{"a"}, false},
{"non-whitespaces", args{"abc"}, false},
{"non-whitespaces and tabs", args{"abc\t"}, false},
{"non-whitespaces and tabs and newlines", args{"abc\t\n"}, false},
{"non-whitespaces and tabs and newlines and non-breaking spaces", args{"abc\t\n\u00A0"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := OnlyWhitespace(tt.args.word); got != tt.want {
t.Errorf("OnlyWhitespace() = %v, want %v", got, tt.want)
}
})
}
}
func TestHashString(t *testing.T) {
type args struct {
s string
}
tests := []struct {
name string
args args
want string
}{
{"empty string", args{""}, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
{"non-empty string", args{"abc"}, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HashString(tt.args.s); got != tt.want {
t.Errorf("HashString() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsSeparator(t *testing.T) {
type args struct {
separator []rune
character rune
}
tests := []struct {
name string
args args
want bool
}{
{"empty separator", args{[]rune{}, 'a'}, false},
{"separator with one rune equal", args{[]rune{'a'}, 'a'}, true},
{"separator with one rune different", args{[]rune{'a'}, 'b'}, false},
{"separator with two runes equal", args{[]rune{'a', 'b'}, 'a'}, true},
{"separator with two runes equal second", args{[]rune{'a', 'b'}, 'b'}, true},
{"separator with two runes different", args{[]rune{'a', 'b'}, 'c'}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsSeparator(tt.args.separator)(tt.args.character); got != tt.want {
t.Errorf("IsSeparator()() = %v, want %v", got, tt.want)
}
})
}
}
func TestContains(t *testing.T) {
type args struct {
s []string
e string
}
tests := []struct {
name string
args args
want bool
}{
{"empty slice", args{[]string{}, "a"}, false},
{"slice with one element equal", args{[]string{"a"}, "a"}, true},
{"slice with one element different", args{[]string{"a"}, "b"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Contains(tt.args.s, tt.args.e); got != tt.want {
t.Errorf("Contains() = %v, want %v", got, tt.want)
}
})
}
}
func TestSeperateRoomString(t *testing.T) {
type args struct {
rooms string
}
tests := []struct {
name string
args args
want []string
}{
{"empty string", args{""}, []string{}},
{"one room", args{"a"}, []string{"a"}},
{"two rooms", args{"a,b"}, []string{"a", "b"}},
{"two rooms with whitespace", args{"a, b"}, []string{"a", "b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SeperateRoomString(tt.args.rooms); !reflect.DeepEqual(got, tt.want) {
t.Errorf("SeperateRoomString() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,26 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package time
import "time"
type MockClock struct {
NowTime time.Time
}
func (m MockClock) Now() time.Time { return m.NowTime }
func (MockClock) After(d time.Duration) <-chan time.Time { return time.After(d) }

View File

@@ -0,0 +1,36 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package time
import (
"github.com/pocketbase/pocketbase/tools/types"
"log/slog"
"time"
)
func ParseTime(timeString string) (time.Time, error) {
return time.Parse("2006-01-02T15:04:05Z", timeString)
}
func ParseAsTypesDatetime(time time.Time) types.DateTime {
dateTime, err := types.ParseDateTime(time)
if err != nil {
slog.Error("Failed to parse time as types.DateTime", "error", err)
return types.DateTime{}
}
return dateTime
}

View File

@@ -0,0 +1,24 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package time
import "time"
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
func (RealClock) After(d time.Duration) <-chan time.Time { return time.After(d) }

View File

@@ -0,0 +1,24 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package time
import "time"
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
}

View File

@@ -0,0 +1,33 @@
package grpc
import (
"context"
"github.com/pocketbase/pocketbase"
pb "htwkalender/common/genproto/modules"
"htwkalender/data-manager/service/db"
)
type FeedServiceHandler struct {
app *pocketbase.PocketBase
pb.UnimplementedFeedServiceServer
}
func (s *FeedServiceHandler) GetFeed(ctx context.Context, in *pb.GetFeedRequest) (*pb.GetFeedResponse, error) {
s.app.Logger().Info(
"Protobuf - GetFeed",
"uuid", in.Id,
)
// get feed from database by UUID
feed, err := db.FindFeedByToken(s.app, in.Id)
if err != nil {
return nil, err
}
// Implement your logic here to fetch feed data based on the UUID
// Example response
return &pb.GetFeedResponse{
Feed: feedToProto(feed),
}, nil
}

View File

@@ -0,0 +1,44 @@
package grpc
import (
pb "htwkalender/common/genproto/modules"
"htwkalender/data-manager/model"
)
func eventToProto(event *model.Event) *pb.Event {
return &pb.Event{
Uuid: event.UUID,
Day: event.Day,
Week: event.Week,
Start: event.Start.String(),
End: event.End.String(),
Name: event.Name,
EventType: event.EventType,
Compulsory: event.Compulsory,
Prof: event.Prof,
Rooms: event.Rooms,
Notes: event.Notes,
BookedAt: event.BookedAt,
Course: event.Course,
Semester: event.Semester,
}
}
func eventsToProto(events model.Events) []*pb.Event {
protoEvents := make([]*pb.Event, 0)
for _, event := range events {
protoEvents = append(protoEvents, eventToProto(&event))
}
return protoEvents
}
func feedToProto(feed *model.Feed) *pb.Feed {
return &pb.Feed{
Id: feed.Id,
Created: feed.Created.String(),
Updated: feed.Updated.String(),
Retrieved: feed.Retrieved.String(),
Modules: feed.Modules,
Deleted: feed.Deleted,
}
}

View File

@@ -0,0 +1,69 @@
package grpc
import (
"context"
"github.com/pocketbase/pocketbase"
pb "htwkalender/common/genproto/modules"
"htwkalender/data-manager/service/db"
)
type ModuleServiceHandler struct {
app *pocketbase.PocketBase
pb.UnimplementedModuleServiceServer
}
func (s *ModuleServiceHandler) GetModule(ctx context.Context, in *pb.GetModuleRequest) (*pb.GetModuleResponse, error) {
s.app.Logger().Info(
"Protobuf - GetModule",
"uuid", in.Uuid,
)
// get module from database by UUID
module, err := db.FindModuleByUUID(s.app, in.Uuid)
if err != nil {
return nil, err
}
events, err := db.FindAllEventsByModule(s.app, module)
if err != nil {
return nil, err
}
//map module Events to proto struct Events
protoEvents := make([]*pb.Event, 0)
for _, event := range events {
protoEvents = append(protoEvents, eventToProto(&event))
}
//map module to proto struct
protoModule := &pb.Module{
Uuid: module.UUID,
Name: module.Name,
Prof: module.Prof,
Course: module.Course,
Semester: module.Semester,
Events: protoEvents,
}
// Implement your logic here to fetch module data based on the UUID
// Example response
return &pb.GetModuleResponse{
Module: protoModule,
}, nil
}
func (s *ModuleServiceHandler) GetEventsForModules(ctx context.Context, in *pb.GetModulesRequest) (*pb.GetEventsResponse, error) {
s.app.Logger().Info(
"Protobuf - GetEventsForModules",
"uuids", in.Uuids,
)
events, err := db.GetPlanForModules(s.app, in.Uuids)
if err != nil {
return nil, err
}
return &pb.GetEventsResponse{
Events: eventsToProto(events),
}, nil
}

View File

@@ -0,0 +1,31 @@
package grpc
import (
"github.com/pocketbase/pocketbase"
"log"
"net"
"google.golang.org/grpc"
pb "htwkalender/common/genproto/modules"
)
func StartGRPCServer(app *pocketbase.PocketBase) {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterModuleServiceServer(s, &ModuleServiceHandler{
app: app,
})
pb.RegisterFeedServiceServer(s, &FeedServiceHandler{
app: app,
})
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

View File

@@ -0,0 +1,50 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package ical
import (
"encoding/json"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
)
func CreateIndividualFeed(requestBody []byte, app *pocketbase.PocketBase) (string, error) {
var modules []model.FeedCollection
err := json.Unmarshal(requestBody, &modules)
if err != nil {
return "", apis.NewNotFoundError("Could not parse request body", err)
}
var icalFeed model.Feed
jsonModules, _ := json.Marshal(modules)
icalFeed.Modules = string(jsonModules)
collection, dbError := db.FindCollection(app, "feeds")
if dbError != nil {
return "", apis.NewNotFoundError("Collection could not be found", dbError)
}
record, err := db.SaveFeed(icalFeed, collection, app)
if err != nil {
return "", apis.NewNotFoundError("Could not save feed", err)
}
return record.Id, nil
}

View File

@@ -0,0 +1,39 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package names
import (
"htwkalender/data-manager/model"
"regexp"
)
func ReplaceTemplateSubStrings(rawString string, event model.Event) string {
re := regexp.MustCompile(`\%(.)`)
return re.ReplaceAllStringFunc(rawString, func(match string) string {
switch match {
case "%%":
return "%"
case "%t":
return event.EventType
case "%p":
return event.Compulsory
default:
return match
}
})
}

View File

@@ -0,0 +1,94 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package names
import (
"htwkalender/data-manager/model"
"testing"
)
func TestReplaceTemplateSubStrings(t *testing.T) {
type args struct {
rawString string
event model.Event
}
tests := []struct {
name string
args args
want string
}{
{
name: "Test 1",
args: args{
rawString: "%t",
event: model.Event{
EventType: "Test",
},
},
want: "Test",
},
{
name: "Test 2",
args: args{
rawString: "%p",
event: model.Event{
Compulsory: "Test",
},
},
want: "Test",
},
{
name: "Test 3",
args: args{
rawString: "%%",
event: model.Event{
EventType: "Test",
},
},
want: "%",
},
{
name: "Test 4",
args: args{
rawString: "%t %p",
event: model.Event{
EventType: "Test",
Compulsory: "Test",
},
},
want: "Test Test",
},
{
name: "Test 5",
args: args{
rawString: "%t %p %%",
event: model.Event{
EventType: "Test",
Compulsory: "Test",
},
},
want: "Test Test %",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ReplaceTemplateSubStrings(tt.args.rawString, tt.args.event); got != tt.want {
t.Errorf("ReplaceTemplateSubStrings() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,295 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package room
import (
"github.com/pocketbase/pocketbase"
"htwkalender/data-manager/model"
"htwkalender/data-manager/service/db"
"htwkalender/data-manager/service/functions"
"htwkalender/model"
"htwkalender/service/db"
"htwkalender/service/functions"
"math"
"regexp"
"strings"
"time"
"github.com/pocketbase/pocketbase"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// maximum number of blocks is around 6 months with 15 minute granularity (180 * 24 * 4 = 17280)
const MaxNumberOfBlocks = 1728000
func GetRooms(app *pocketbase.PocketBase) ([]string, error) {
rooms, err := db.GetRooms(app)
if err != nil {
return nil, err
} else {
return rooms, nil
}
}
func GetRoomScheduleForDay(app *pocketbase.PocketBase, room string, date string) ([]model.AnonymizedEventDTO, error) {
roomSchedule, err := db.GetRoomScheduleForDay(app, room, date)
if err != nil {
return nil, err
}
anonymizedRoomSchedule := anonymizeRooms(roomSchedule)
return anonymizedRoomSchedule, nil
}
func GetRoomSchedule(app *pocketbase.PocketBase, room string, from string, to string) ([]model.AnonymizedEventDTO, error) {
roomSchedule, err := db.GetRoomSchedule(app, room, from, to)
if err != nil {
return nil, err
}
anonymizedRoomSchedule := anonymizeRooms(roomSchedule)
return anonymizedRoomSchedule, nil
}
/**
* Get the room occupancy for all rooms within a given time range
* @param app pocketbase instance
* @param from start time of the occupancy list
* @param to end time of the occupancy list
* @param granularity number of minutes for one block
* @return room occupancy list
* @return error if the database query fails
*/
func GetRoomOccupancyList(app *pocketbase.PocketBase, from string, to string, granularity int) (model.RoomOccupancyList, error) {
// try parsing the time strings
fromTime, err := time.Parse(time.RFC3339, from)
if err != nil {
return model.RoomOccupancyList{}, err
}
toTime, err := time.Parse(time.RFC3339, to)
if err != nil {
return model.RoomOccupancyList{}, err
}
// calculate the number of blocks for the given time range and granularity
timeDifference := toTime.Sub(fromTime)
numberOfBlocks := int(math.Ceil(timeDifference.Minutes() / float64(granularity)))
numberOfBlocks = min(numberOfBlocks, MaxNumberOfBlocks)
roomOccupancyList := emptyRoomOccupancyList(fromTime, granularity, numberOfBlocks)
// get all events within the time range
events, err := db.GetEventsThatCollideWithTimeRange(app, fromTime, toTime)
if err != nil {
return model.RoomOccupancyList{}, err
}
rooms, err := getRelevantRooms(app)
if err != nil {
return model.RoomOccupancyList{}, err
}
for _, room := range rooms {
// get the schedule for only this room
roomEvents := functions.Filter(events, func(event model.Event) bool {
return strings.Contains(event.Rooms, room)
})
// encode the room schedule binary and add it to the list
roomOccupancy, err := createRoomOccupancy(room, roomEvents, fromTime, granularity, numberOfBlocks)
if err != nil {
return model.RoomOccupancyList{}, err
}
roomOccupancyList.Rooms = append(roomOccupancyList.Rooms, roomOccupancy)
}
return roomOccupancyList, nil
}
/**
* Get all rooms from the database and filter them by a regex
* @param app pocketbase instance
* @return all rooms that match the regex
* @return error if the database query fails
*/
func getRelevantRooms(app *pocketbase.PocketBase) ([]string, error) {
// get all rooms
rooms, err := db.GetRooms(app)
if err != nil {
return nil, err
}
// filter by regex for the room name
roomRegex := regexp.MustCompile(".*")
return functions.Filter(rooms, roomRegex.MatchString), nil
}
/*
* Create an empty room occupancy list
* @param from start time of the occupancy list
* @param granularity number of minutes for one block
* @param blockCount number of blocks that can be either occupied or free
*/
func emptyRoomOccupancyList(from time.Time, granularity int, blockCount int) model.RoomOccupancyList {
return model.RoomOccupancyList{
Start: from,
Granularity: granularity,
Blocks: blockCount,
Rooms: []model.RoomOccupancy{},
}
}
/*+
* Create the room occupancy for a given room
* @param room room name
* @param events events that could block the room (or be free)
* @param start start time of the schedule
* @param granularity number of minutes for one block
* @param blockCount number of blocks
* @return room occupancy for the given room
* @return error if encoding the room schedule fails
*/
func createRoomOccupancy(room string, events []model.Event, start time.Time, granularity int, blockCount int) (model.RoomOccupancy, error) {
roomSchedule := anonymizeRooms(events)
occupancy, err := encodeRoomSchedule(roomSchedule, start, granularity, blockCount)
if err != nil {
return model.RoomOccupancy{}, err
}
return model.RoomOccupancy{
Name: room,
Occupancy: primitive.Binary{
Subtype: 0,
Data: occupancy,
},
}, nil
}
/*
* Transform the events to anonymized events throwing away all unnecessary information
* @param events events to be anonymized
* @see Event.AnonymizeEvent
* @return anonymized events
*/
func anonymizeRooms(events []model.Event) []model.AnonymizedEventDTO {
var anonymizedEvents []model.AnonymizedEventDTO
for _, event := range events {
anonymizedEvents = append(anonymizedEvents, event.AnonymizeEvent())
}
return anonymizedEvents
}
func GetFreeRooms(app *pocketbase.PocketBase, from time.Time, to time.Time) ([]string, error) {
rooms, err := db.GetRooms(app)
if err != nil {
return nil, err
}
var events model.Events
events, err = db.GetEventsThatCollideWithTimeRange(app, from, to)
if err != nil {
return nil, err
}
freeRooms := removeRoomsThatHaveEvents(rooms, events)
return freeRooms, nil
}
// Remove all rooms from the list that have events in the given time range
func removeRoomsThatHaveEvents(rooms []string, schedule []model.Event) []string {
var freeRooms []string
for _, room := range rooms {
if !isRoomInSchedule(room, schedule) {
freeRooms = append(freeRooms, room)
}
}
return freeRooms
}
// Check if a room is in the schedule
func isRoomInSchedule(room string, schedule []model.Event) bool {
for _, event := range schedule {
if event.Course != "Sport" {
rooms := functions.SeperateRoomString(event.Rooms)
// check if room is in rooms
for _, r := range rooms {
if r == room {
return true
}
}
} else {
if event.Rooms == room {
return true
}
}
}
return false
}
/**
* Encode the room schedule to a byte array
*
* @param roomSchedule events that block the room
* @param start start time of the schedule
* @param granularity number of minutes for one block
* @param blockCount number of blocks
*
* @return byte array of the encoded room schedule
* @return error if encoding fails
*/
func encodeRoomSchedule(roomSchedule []model.AnonymizedEventDTO, start time.Time, granularity int, blockCount int) ([]byte, error) {
// Create empty occupancy array with blockCount bits
byteCount := int(math.Ceil(float64(blockCount) / 8))
occupancy := make([]byte, byteCount)
// Iterate over all events in the schedule
for _, event := range roomSchedule {
// skip if room is not occupied or end time is not after start time
if event.Free || !event.Start.Time().Before(event.End.Time()) {
continue
}
// Calculate the start and end block of the event
startBlock := int(
math.Floor(event.Start.Time().Sub(start).Minutes() / float64(granularity)),
)
endBlock := int(
math.Ceil(event.End.Time().Sub(start).Minutes() / float64(granularity)),
)
occupyBlocks(occupancy, startBlock, endBlock, blockCount)
}
return occupancy, nil
}
/**
* Set the bits of the occupancy array for the given block range
* to 1
*
* @param occupancy byte array of the occupancy
* @param startBlock start block (bit defined by granularity) of the event
* @param endBlock end block of the event
* @param blockCount number of blocks (bits) in the occupancy array
*/
func occupyBlocks(occupancy []byte, startBlock int, endBlock int, blockCount int) {
lowerBound := max(0, min(startBlock, blockCount))
upperBound := min(max(endBlock, lowerBound), blockCount)
// Iterate over all blocks of the event
for i := lowerBound; i < upperBound; i++ {
occupancy[i/8] |= 1 << (7 - i%8)
}
}

View File

@@ -0,0 +1,625 @@
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
//Copyright (C) 2024 HTWKalender support@htwkalender.de
//This program is free software: you can redistribute it and/or modify
//it under the terms of the GNU Affero General Public License as published by
//the Free Software Foundation, either version 3 of the License, or
//(at your option) any later version.
//This program is distributed in the hope that it will be useful,
//but WITHOUT ANY WARRANTY; without even the implied warranty of
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//GNU Affero General Public License for more details.
//You should have received a copy of the GNU Affero General Public License
//along with this program. If not, see <https://www.gnu.org/licenses/>.
package room
import (
"htwkalender/data-manager/model"
"reflect"
"testing"
"time"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAnonymizeRooms(t *testing.T) {
type args struct {
events []model.Event
}
tests := []struct {
name string
args args
want []model.AnonymizedEventDTO
}{
{
name: "anonymize single event",
args: args{
events: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room",
Notes: "Secret",
BookedAt: "Secret",
Course: "42INM-3",
Semester: "ws",
Compulsory: "p",
},
},
},
want: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Rooms: "Room",
Free: false,
},
},
},
{
name: "anonymize empty list",
args: args{
events: []model.Event{},
},
want: nil,
},
{
name: "anonymize multiple events",
args: args{
events: []model.Event{
{
UUID: "testUUID1",
Day: "Montag",
Week: "51",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Incognito",
EventType: "V",
Prof: "Prof. Dr. Incognito",
Rooms: "Room",
Notes: "Incognito",
BookedAt: "Incognito",
Course: "69INM-2",
Semester: "sose",
Compulsory: "p",
},
{
UUID: "testUUID2",
Day: "Dienstag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Private",
EventType: "S",
Prof: "Prof.In. Dr.-Ing. Private",
Rooms: "Room",
Notes: "Private",
BookedAt: "Private",
Course: "42MIM-3",
Semester: "ws",
Compulsory: "w",
},
},
},
want: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "51",
Start: types.DateTime{},
End: types.DateTime{},
Rooms: "Room",
Free: false,
},
{
Day: "Dienstag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Rooms: "Room",
Free: false,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := anonymizeRooms(tt.args.events); !reflect.DeepEqual(got, tt.want) {
t.Errorf("anonymizeRooms() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsRoomInSchedule(t *testing.T) {
type args struct {
room string
schedule []model.Event
}
tests := []struct {
name string
args args
want bool
}{
{
name: "room is in schedule",
args: args{
room: "Room",
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room",
Notes: "Secret",
},
},
},
want: true,
},
{
name: "room is not in schedule",
args: args{
room: "Z324",
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Bond",
Rooms: "LI007",
Notes: "Keine Zeit für die Uni",
},
},
},
want: false,
},
{
name: "schedule event.Course is sport",
args: args{
room: "Klettergerüst",
schedule: []model.Event{
{
UUID: "903784265784639527",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Hampelmann",
EventType: "S",
Prof: "Prof. Dr. Bewegung",
Rooms: "Klettergerüst",
Notes: "A apple a day keeps the doctor away",
Course: "Sport",
},
},
},
want: true,
},
{
name: "schedule event.Course is sport with different room",
args: args{
room: "HTWK Sportplatz",
schedule: []model.Event{
{
UUID: "903784265784639527",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Hampelmann",
EventType: "S",
Prof: "Prof. Dr. Bewegung",
Rooms: "Klettergerüst",
Notes: "A apple a day keeps the doctor away",
Course: "Sport",
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRoomInSchedule(tt.args.room, tt.args.schedule); got != tt.want {
t.Errorf("isRoomInSchedule() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetFreeRooms(t *testing.T) {
type args struct {
rooms []string
schedule []model.Event
}
tests := []struct {
name string
args args
want []string
}{
{
name: "remove room1 from list",
args: args{
rooms: []string{
"Room1",
"Room2",
"Room3",
},
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room1",
Notes: "Secret",
},
},
},
want: []string{
"Room2",
"Room3",
},
},
{
name: "remove room2 from list",
args: args{
rooms: []string{
"Room1",
"Room2",
"Room3",
},
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room3",
Notes: "Secret",
},
},
},
want: []string{
"Room1",
"Room2",
},
},
{
name: "remove no room from list",
args: args{
rooms: []string{
"Room1",
"Room2",
"Room3",
},
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room4",
Notes: "Secret",
},
},
},
want: []string{
"Room1",
"Room2",
"Room3",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := removeRoomsThatHaveEvents(tt.args.rooms, tt.args.schedule); !reflect.DeepEqual(got, tt.want) {
t.Errorf("removeRoomsThatHaveEvents() = %v, want %v", got, tt.want)
}
})
}
}
func Test_encodeRoomSchedule(t *testing.T) {
testTime, _ := time.Parse(time.RFC3339, "2024-12-24T12:00:00Z")
testDateTime, _ := types.ParseDateTime(testTime)
testDateTime_m15, _ := types.ParseDateTime(testTime.Add(-time.Minute * 15))
testDateTime_p10, _ := types.ParseDateTime(testTime.Add(time.Minute * 10))
testDateTime_p15, _ := types.ParseDateTime(testTime.Add(time.Minute * 15))
testDateTime_p30, _ := types.ParseDateTime(testTime.Add(time.Minute * 30))
testDateTime_p45, _ := types.ParseDateTime(testTime.Add(time.Minute * 45))
testDateTime_late, _ := types.ParseDateTime(testTime.Add(time.Hour * 100))
type args struct {
roomSchedule []model.AnonymizedEventDTO
start time.Time
granularity int
blockCount int
}
tests := []struct {
name string
args args
want []byte
wantErr bool
}{
{
name: "encode event without length",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_p10,
End: testDateTime_p10,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 4,
},
want: []byte{
0x00,
},
wantErr: false,
},
{
name: "ignore event with start time after end time",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_p30,
End: testDateTime_p10,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 4,
},
want: []byte{
0x00,
},
wantErr: false,
},
{
name: "encode time table without length",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime,
End: testDateTime_p10,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 0,
},
want: []byte{},
wantErr: false,
},
{
name: "encode time table without events",
args: args{
roomSchedule: []model.AnonymizedEventDTO{},
start: testTime,
granularity: 15,
blockCount: 24,
},
want: []byte{
0x00, 0x00, 0x00,
},
wantErr: false,
},
{
name: "encode time table with single event",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_p30,
End: testDateTime_late,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 30,
blockCount: 50,
},
want: []byte{
0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0,
},
},
{
name: "ignore free event",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_p15,
End: testDateTime_p45,
Rooms: "Room",
Free: false,
},
{
Day: "Montag",
Week: "52",
Start: testDateTime,
End: testDateTime_p30,
Rooms: "Room",
Free: true,
},
},
start: testTime,
granularity: 15,
blockCount: 50,
},
want: []byte{
0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
},
},
{
name: "encode time table with multiple events",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime,
End: testDateTime_p15,
Rooms: "Room",
Free: false,
},
{
Day: "Montag",
Week: "52",
Start: testDateTime_p30,
End: testDateTime_p45,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 4,
},
want: []byte{
0xA0,
},
},
{
name: "encode time table with multiple unordered events",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_p30,
End: testDateTime_p45,
Rooms: "Room",
Free: false,
},
{
Day: "Montag",
Week: "52",
Start: testDateTime,
End: testDateTime_p15,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 4,
},
want: []byte{
0xA0,
},
},
{
name: "encode time table with overlapping events",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_p15,
End: testDateTime_p30,
Rooms: "Room",
Free: false,
},
{
Day: "Montag",
Week: "52",
Start: testDateTime,
End: testDateTime_p45,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 4,
},
want: []byte{
0xE0,
},
},
{
name: "consider events starting before the start time",
args: args{
roomSchedule: []model.AnonymizedEventDTO{
{
Day: "Montag",
Week: "52",
Start: testDateTime_m15,
End: testDateTime_p15,
Rooms: "Room",
Free: false,
},
},
start: testTime,
granularity: 15,
blockCount: 4,
},
want: []byte{
0x80,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := encodeRoomSchedule(tt.args.roomSchedule, tt.args.start, tt.args.granularity, tt.args.blockCount)
if (err != nil) != tt.wantErr {
t.Errorf("encodeRoomSchedule() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("encodeRoomSchedule() = %v, want %v", got, tt.want)
}
})
}
}