diff --git a/api/router.go b/api/router.go index f38b96eb0..7224d994c 100755 --- a/api/router.go +++ b/api/router.go @@ -42,7 +42,7 @@ func ConfigGinRouter(router *gin.Engine) { configWorkerRouter(router, daoWrapper) configNotificationsRouter(router, daoWrapper) configInfoPageRouter(router, daoWrapper) - configGinSearchRouter(router, daoWrapper) + configGinSearchRouter(router, daoWrapper, tools.NewMeiliSearchFunctions()) configAuditRouter(router, daoWrapper) configGinBookmarksRouter(router, daoWrapper) configMaintenanceRouter(router, daoWrapper) diff --git a/api/search.go b/api/search.go index ca48e76e8..d05c9a45d 100644 --- a/api/search.go +++ b/api/search.go @@ -1,28 +1,672 @@ package api import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" "net/http" + "regexp" + "strconv" + "strings" + "time" "github.com/TUM-Dev/gocast/dao" + "github.com/TUM-Dev/gocast/model" "github.com/TUM-Dev/gocast/tools" "github.com/gin-gonic/gin" + "github.com/meilisearch/meilisearch-go" ) -func configGinSearchRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) { - routes := searchRoutes{daoWrapper} +func configGinSearchRouter(router *gin.Engine, daoWrapper dao.DaoWrapper, meiliSearchInstance tools.MeiliSearchInterface) { + routes := searchRoutes{daoWrapper, meiliSearchInstance} searchGroup := router.Group("/api/search") + searchGroup.GET("", routes.search) withStream := searchGroup.Group("/stream/:streamID") withStream.Use(tools.InitStream(daoWrapper)) withStream.GET("/subtitles", routes.searchSubtitles) + + searchGroup.GET("/courses", routes.searchCourses) } type searchRoutes struct { dao.DaoWrapper + m tools.MeiliSearchInterface } +const ( + FilterMaxCoursesCount = 3 + DefaultLimit = 10 +) + +type MeiliSearchMap map[string]any + func (r searchRoutes) searchSubtitles(c *gin.Context) { s := c.MustGet("TUMLiveContext").(tools.TUMLiveContext).Stream q := c.Query("q") - c.JSON(http.StatusOK, tools.SearchSubtitles(q, s.ID)) + c.JSON(http.StatusOK, r.m.SearchSubtitles(q, s.ID)) +} + +/* +Expected format for URL Parameters: +q=...&limit=... with q is the search query and limit is the maximum number of results + +If you want to search in only one semester you can add the following parameters: +semester=... or firstSemester=...&lastSemester=... with semester being the semester in the format +If you want to search in multiple semesters you can add the following parameters: +semester=..., ... with semester being a comma separated list of semesters in the format + +If you want to search in only one course you can add the following parameters: +course=... with course being the course in the format +If you want to search in multiple courses you can add the following parameters: +course=..., ... with course being a comma separated list of courses in the format +*/ +func (r searchRoutes) searchCourses(c *gin.Context) { + user, query, limit := getDefaultParameters(c) + + res, err := semesterSearchHelper(c, r.m, query, int64(limit), user, true) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + if res == nil { + logger.Warn("meilisearch did not return any search result") + c.AbortWithStatus(http.StatusInternalServerError) + return + } + checkAndFillResponse(c, user, int64(limit), r.DaoWrapper, res, false) + c.JSON(http.StatusOK, responseToMap(res)) +} + +func (r searchRoutes) search(c *gin.Context) { + user, query, limit := getDefaultParameters(c) + + var res *meilisearch.MultiSearchResponse + if courseParam := c.Query("course"); courseParam != "" { + courses, errorCode := parseCourses(c, r.DaoWrapper, courseParam) + if errorCode == 2 { + // logger warning already done in parseCourses + c.AbortWithStatus(http.StatusInternalServerError) + return + } else if errorCode != 0 || len(courses) > FilterMaxCoursesCount { + c.AbortWithStatus(http.StatusBadRequest) + return + } + for _, course := range courses { + if !user.IsEligibleToWatchCourse(course) { + c.AbortWithStatus(http.StatusBadRequest) + return + } + } + res = r.m.Search(query, int64(limit), 3, "", meiliStreamFilter(c, user, model.Semester{}, courses), meiliSubtitleFilter(user, courses)) + if res == nil { + logger.Warn("meilisearch did not return any search result") + c.AbortWithStatus(http.StatusInternalServerError) + return + } + checkAndFillResponse(c, user, int64(limit), r.DaoWrapper, res, true) + c.JSON(http.StatusOK, responseToMap(res)) + return + } + + res, err := semesterSearchHelper(c, r.m, query, int64(limit), user, false) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + if res == nil { + logger.Warn("meilisearch did not return any search result") + c.AbortWithStatus(http.StatusInternalServerError) + return + } + checkAndFillResponse(c, user, int64(limit), r.DaoWrapper, res, false) + c.JSON(http.StatusOK, responseToMap(res)) +} + +func semesterSearchHelper(c *gin.Context, m tools.MeiliSearchInterface, query string, limit int64, user *model.User, courseSearchOnly bool) (*meilisearch.MultiSearchResponse, error) { + var res *meilisearch.MultiSearchResponse + firstSemesterParam := c.Query("firstSemester") + lastSemesterParam := c.Query("lastSemester") + semestersParam := c.Query("semester") + if firstSemesterParam != "" && lastSemesterParam != "" || semestersParam != "" { + var firstSemester, lastSemester model.Semester + semesters1, err1 := parseSemesters(firstSemesterParam) + semesters2, err2 := parseSemesters(lastSemesterParam) + semesters, err3 := parseSemesters(semestersParam) + if (err1 != nil || err2 != nil || len(semesters1) != 1 || len(semesters2) != 1) && err3 != nil { + return nil, errors.New("wrong parameters") + } + if len(semesters1) > 0 && len(semesters2) > 0 { + firstSemester = semesters1[0] + lastSemester = semesters2[0] + } + + isSingleSemesterSearch, singleSemester := determineSingleSemester(firstSemester, lastSemester, semesters) + if !courseSearchOnly && isSingleSemesterSearch { + // single semester search + res = m.Search(query, limit, 6, meiliCourseFilter(c, user, singleSemester, singleSemester, nil), meiliStreamFilter(c, user, singleSemester, nil), "") + } else { + // multiple semester or course only search + res = m.Search(query, limit, 4, meiliCourseFilter(c, user, firstSemester, lastSemester, semesters), "", "") + } + return res, nil + } + + sem1 := model.Semester{TeachingTerm: "S"} + sem2 := model.Semester{TeachingTerm: "W", Year: 3000} + return m.Search(query, limit, 4, meiliCourseFilter(c, user, sem1, sem2, nil), "", ""), nil +} + +type SearchCourseDTO struct { + Name string `json:"name"` + Slug string `json:"slug"` + Year int `json:"year"` + TeachingTerm string `json:"semester"` +} + +type SearchStreamDTO struct { + ID uint `json:"ID"` + Name string `json:"name"` + Description string `json:"description"` + CourseName string `json:"courseName"` + Year int `json:"year"` + TeachingTerm string `json:"semester"` + CourseSlug string `json:"courseSlug"` +} + +type SearchSubtitlesDTO struct { + StreamID uint `json:"streamID"` + Timestamp int64 `json:"timestamp"` + TextPrev string `json:"textPrev"` // the previous subtitle line + Text string `json:"text"` + TextNext string `json:"textNext"` // the next subtitle line + StreamName string `json:"streamName"` + StreamStartTime time.Time `json:"streamStartTime"` + StreamEndTime time.Time `json:"streamEndTime"` + CourseName string `json:"courseName"` + CourseSlug string `json:"courseSlug"` + CourseYear int `json:"year"` + CourseTeachingTerm string `json:"semester"` +} + +// checkAndFillResponse takes the response of meilisearch and filters out all courses/streams/subtitles which the user is not allowed to see (checking) +// this is necessary because updating the course in the database (e.g. changing the visibility for a course from public to loggedin) does not update meilisearch data until the next day +// additionally it adds information to the results which is not saved in meili (fill) +// --- +// canSearchHiddenCourses indicates whether the response may include streams or subtitles of a hidden course +// should only be true when the user has explicitly named the hidden course he wants to search through in the url params +func checkAndFillResponse(c *gin.Context, user *model.User, limit int64, daoWrapper dao.DaoWrapper, response *meilisearch.MultiSearchResponse, canSearchHiddenCourses bool) { + var userEligibleToSeeResultsOfHiddenCourse func(course model.Course) bool + if canSearchHiddenCourses { + userEligibleToSeeResultsOfHiddenCourse = user.IsEligibleToWatchCourse + } else { + userEligibleToSeeResultsOfHiddenCourse = user.IsEligibleToSearchForCourse + } + + for i, res := range response.Results { + switch res.IndexUID { + case "STREAMS": + hits := res.Hits + res.Hits = []any{} + response.Results[i] = meilisearch.SearchResponse{} + + var meiliStreams []SearchStreamDTO + temp, err := json.Marshal(hits) + if err != nil { // shouldn't happen + logger.Warn("meilisearch response post processing streams json marshaling error", "err", err) + continue + } + err = json.Unmarshal(temp, &meiliStreams) + if err != nil { // shouldn't happen + logger.Warn("meilisearch response streams post processing streams json unmarshaling error", "err", err) + continue + } + + for _, meiliStream := range meiliStreams { + stream, err := daoWrapper.StreamsDao.GetStreamByID(c, strconv.FormatUint(uint64(meiliStream.ID), 10)) + if err != nil { + continue + } + course, err := daoWrapper.CoursesDao.GetCourseById(c, stream.CourseID) + if err != nil { + continue + } + + meiliStream.CourseSlug = course.Slug + if userEligibleToSeeResultsOfHiddenCourse(course) && (!stream.Private || user.IsAdminOfCourse(course)) { + res.Hits = append(res.Hits, meiliStream) + } + + if len(res.Hits) >= int(limit) { + break + } + } + response.Results[i] = res + case "COURSES": + hits := res.Hits + res.Hits = []any{} + response.Results[i] = meilisearch.SearchResponse{} + + var meiliCourses []SearchCourseDTO + temp, err := json.Marshal(hits) + if err != nil { // shouldn't happen + logger.Warn("meilisearch response post processing courses json marshaling error", "err", err) + continue + } + err = json.Unmarshal(temp, &meiliCourses) + if err != nil { // shouldn't happen + logger.Warn("meilisearch response post processing courses json unmarshaling error", "err", err) + continue + } + + for _, meiliCourse := range meiliCourses { + course, err := daoWrapper.CoursesDao.GetCourseBySlugYearAndTerm(c, meiliCourse.Slug, meiliCourse.TeachingTerm, meiliCourse.Year) + if err == nil && user.IsEligibleToSearchForCourse(course) { + res.Hits = append(res.Hits, meiliCourse) + } + + if len(res.Hits) >= int(limit) { + break + } + } + response.Results[i] = res + case "SUBTITLES": + hits := res.Hits + res.Hits = []any{} + response.Results[i] = meilisearch.SearchResponse{} + + var meiliSubtitles []SearchSubtitlesDTO + temp, err := json.Marshal(hits) + if err != nil { // shouldn't happen + logger.Warn("meilisearch response post processing subtitles json marshaling error", "err", err) + continue + } + err = json.Unmarshal(temp, &meiliSubtitles) + if err != nil { // shouldn't happen + logger.Warn("meilisearch response post processing subtitles json unmarshaling error", "err", err) + continue + } + + for _, meiliSubtitle := range meiliSubtitles { + stream, err := daoWrapper.StreamsDao.GetStreamByID(c, strconv.FormatUint(uint64(meiliSubtitle.StreamID), 10)) + if err != nil { + continue + } + course, err := daoWrapper.CoursesDao.GetCourseById(c, stream.CourseID) + if err != nil { + continue + } + + meiliSubtitle.StreamName = stream.Name + meiliSubtitle.StreamStartTime = stream.Start + meiliSubtitle.StreamEndTime = stream.End + meiliSubtitle.CourseSlug = course.Slug + meiliSubtitle.CourseName = course.Name + meiliSubtitle.CourseYear = course.Year + meiliSubtitle.CourseTeachingTerm = course.TeachingTerm + if userEligibleToSeeResultsOfHiddenCourse(course) && (!stream.Private || user.IsAdminOfCourse(course)) { + res.Hits = append(res.Hits, meiliSubtitle) + } + + if len(res.Hits) >= int(limit) { + break + } + } + response.Results[i] = res + default: + continue + } + } +} + +// meilisearch filters + +// meiliSubtitleFilter returns a filter conforming to MeiliSearch filter format that can be used for filtering subtitles +// +// Checking eligibility to search in each course in courses is the caller's responsibility +func meiliSubtitleFilter(user *model.User, courses []model.Course) string { + if len(courses) == 0 { + return "" + } + + var streamIDs []uint + for _, course := range courses { + admin := user.IsAdminOfCourse(course) + for _, stream := range course.Streams { + if !stream.Private || admin { + streamIDs = append(streamIDs, stream.ID) + } + } + } + return fmt.Sprintf("streamID IN %s", uintSliceToString(streamIDs)) +} + +// meiliStreamFilter returns a filter conforming to MeiliSearch filter format that can be used for filtering streams +// +// Checking eligibility to search for courses and validation of model.Semester format is the caller's responsibility +func meiliStreamFilter(c *gin.Context, user *model.User, semester model.Semester, courses []model.Course) string { + if courses != nil { + administeredCourses := make([]model.Course, 0) + nonAdministeredCourses := make([]model.Course, 0) + for _, course := range courses { + if user.IsAdminOfCourse(course) { + administeredCourses = append(administeredCourses, course) + } else { + nonAdministeredCourses = append(nonAdministeredCourses, course) + } + } + if user == nil || len(administeredCourses) == 0 { + return fmt.Sprintf("courseID IN %s AND private = 0", courseSliceToString(nonAdministeredCourses)) + } + return fmt.Sprintf("(courseID IN %s OR (courseID IN %s AND private = 0))", courseSliceToString(administeredCourses), courseSliceToString(nonAdministeredCourses)) + } + + semesterFilter := fmt.Sprintf("(year = %d AND semester = \"%s\")", semester.Year, semester.TeachingTerm) + if user != nil && user.Role == model.AdminType { + return semesterFilter + } + + var permissionFilter string + if user == nil { + permissionFilter = "(visibility = \"public\" AND private = 0)" + } else { + enrolledCourses := user.CoursesBetweenSemestersWithoutAdministeredCourses(semester, semester) + enrolledCoursesFilter := courseSliceToString(enrolledCourses) + if len(user.AdministeredCourses) == 0 { + permissionFilter = fmt.Sprintf("((visibility = \"loggedin\" OR visibility = \"public\" OR (visibility = \"enrolled\" AND courseID IN %s)) AND private = 0)", enrolledCoursesFilter) + } else { + administeredCourses := user.AdministeredCoursesBetweenSemesters(semester, semester) + administeredCoursesFilter := courseSliceToString(administeredCourses) + permissionFilter = fmt.Sprintf("((visibility = \"loggedin\" OR visibility = \"public\" OR (visibility = \"enrolled\" AND courseID IN %s)) AND private = 0 OR courseID IN %s)", enrolledCoursesFilter, administeredCoursesFilter) + } + } + + if permissionFilter == "" { + return semesterFilter + } + return fmt.Sprintf("(%s AND %s)", permissionFilter, semesterFilter) +} + +// meiliCourseFilter returns a filter conforming to MeiliSearch filter format that can be used for filtering courses +// +// # Validation of model.Semester format is the caller's responsibility +// +// ignores either semesters or firstSemester/lastSemester, depending on semesters == nil +func meiliCourseFilter(c *gin.Context, user *model.User, firstSemester model.Semester, lastSemester model.Semester, semesters []model.Semester) string { + semesterFilter := meiliSemesterFilter(firstSemester, lastSemester, semesters) + if user != nil && user.Role == model.AdminType { + return semesterFilter + } + + var permissionFilter string + if user == nil { + permissionFilter = "(visibility = \"public\")" + } else { + var enrolledCourses []model.Course + if semesters == nil { + enrolledCourses = user.CoursesBetweenSemestersWithoutAdministeredCourses(firstSemester, lastSemester) + } else { + enrolledCourses = user.CoursesForSemestersWithoutAdministeredCourses(semesters) + } + enrolledCoursesFilter := courseSliceToString(enrolledCourses) + if len(user.AdministeredCourses) == 0 { + permissionFilter = fmt.Sprintf("(visibility = \"loggedin\" OR visibility = \"public\" OR (visibility = \"enrolled\" AND ID IN %s))", enrolledCoursesFilter) + } else { + var administeredCourses []model.Course + if semesters == nil { + administeredCourses = user.AdministeredCoursesBetweenSemesters(firstSemester, lastSemester) + } else { + administeredCourses = user.AdministeredCoursesForSemesters(semesters) + } + administeredCoursesFilter := courseSliceToString(administeredCourses) + permissionFilter = fmt.Sprintf("(visibility = \"loggedin\" OR visibility = \"public\" OR (visibility = \"enrolled\" AND ID IN %s) OR ID IN %s)", enrolledCoursesFilter, administeredCoursesFilter) + } + } + + if semesterFilter == "" || permissionFilter == "" { + return permissionFilter + semesterFilter + } + return fmt.Sprintf("(%s AND %s)", permissionFilter, semesterFilter) +} + +// meiliSemesterFilter returns a filter conforming to MeiliSearch filter format +// +// # Validation of model.Semester format is the caller's responsibility +// +// ignores either semesters or firstSemester/lastSemester, depending on semesters == nil +func meiliSemesterFilter(firstSemester model.Semester, lastSemester model.Semester, semesters []model.Semester) string { + if len(semesters) == 0 && firstSemester.Year < 1900 && lastSemester.Year > 2800 { + return "" + } + + if semesters == nil { + // single semester + if firstSemester.Year == lastSemester.Year && firstSemester.TeachingTerm == lastSemester.TeachingTerm { + return fmt.Sprintf("(year = %d AND semester = \"%s\")", firstSemester.Year, firstSemester.TeachingTerm) + } + + // multiple semesters + var constraint1, constraint2 string + if firstSemester.TeachingTerm == "W" { + constraint1 = fmt.Sprintf("(year = %d AND semester = \"%s\")", firstSemester.Year, firstSemester.TeachingTerm) + } else { + constraint1 = fmt.Sprintf("year = %d", firstSemester.Year) + } + if lastSemester.TeachingTerm == "S" { + constraint2 = fmt.Sprintf("(year = %d AND semester = \"%s\")", lastSemester.Year, lastSemester.TeachingTerm) + } else { + constraint2 = fmt.Sprintf("year = %d", lastSemester.Year) + } + if firstSemester.Year+1 < lastSemester.Year { + return fmt.Sprintf("(%s OR (year > %d AND year < %d) OR %s)", constraint1, firstSemester.Year, lastSemester.Year, constraint2) + } + return fmt.Sprintf("(%s OR %s)", constraint1, constraint2) + } + + semesterStringsSlice := make([]string, len(semesters)) + for i, semester := range semesters { + semesterStringsSlice[i] = fmt.Sprintf("(year = %d AND semester = \"%s\")", semester.Year, semester.TeachingTerm) + } + filter := "(" + strings.Join(semesterStringsSlice, " OR ") + ")" + return filter +} + +// Utility functions + +func getDefaultParameters(c *gin.Context) (*model.User, string, uint64) { + user := c.MustGet("TUMLiveContext").(tools.TUMLiveContext).User + query := c.Query("q") + limit, err := strconv.ParseUint(c.Query("limit"), 10, 16) + if err != nil || limit > math.MaxInt64 { // second condition should never evaluate to true (maximum bitSize for ParseUint is 16) + limit = DefaultLimit + } + return user, query, limit +} + +func responseToMap(res *meilisearch.MultiSearchResponse) MeiliSearchMap { + msm := make(MeiliSearchMap) + if res == nil { + return msm + } + for _, r := range res.Results { + msm[r.IndexUID] = r.Hits + } + return msm +} + +// parseSemesters parses the URL Parameter semester and returns a slice containing every semester in the parameter or an error code +func parseSemesters(semestersParam string) ([]model.Semester, error) { + if semestersParam == "" { + return nil, errors.New("empty semestersParam") + } + semesterStrings := strings.Split(semestersParam, ",") + + regex, err := regexp.Compile(`^[0-9]{4}[WS]$`) //nolint:all + if err != nil { + logger.Warn("regex compile error", "err", err) + return nil, err + } + + semesters := make([]model.Semester, len(semesterStrings)) + for i, semester := range semesterStrings { + if regex.MatchString(semester) { + year, _ := strconv.Atoi(semester[:4]) + semesters[i] = model.Semester{ + TeachingTerm: semester[4:], + Year: year, + } + } else { + return nil, errors.New(fmt.Sprintf("semester %s is not valid", semester)) //nolint:all + } + } + return semesters, nil +} + +// parseCourses parses the URL Parameter course (urlParamCourse) and returns a slice containing every course in the parameter or an error code +// +// Checking if the user is allowed to see returned courses is the caller's responsibility +func parseCourses(c *gin.Context, daoWrapper dao.DaoWrapper, urlParamCourse string) ([]model.Course, uint) { + coursesStrings := strings.Split(urlParamCourse, ",") + + regex, err := regexp.Compile(`^.+[0-9]{4}[WS]$`) //nolint:all + if err != nil { + logger.Warn("regex compiler error", "err", err) + return nil, 2 + } + + courses := make([]model.Course, len(coursesStrings)) + for i, courseString := range coursesStrings { + if !regex.MatchString(courseString) { + return nil, 1 + } + length := len(courseString) + year, _ := strconv.Atoi(courseString[length-5 : length-1]) + course, err := daoWrapper.CoursesDao.GetCourseBySlugYearAndTerm(c, courseString[:length-5], courseString[length-1:], year) + if err != nil { + return nil, 1 + } + courses[i] = course + } + return courses, 0 +} + +func courseSliceToString(courses []model.Course) string { + if len(courses) == 0 { + return "[]" + } + idsStringSlice := make([]string, len(courses)) + for i, c := range courses { + idsStringSlice[i] = strconv.FormatUint(uint64(c.ID), 10) + } + filter := "[" + strings.Join(idsStringSlice, ",") + "]" + return filter +} + +func uintSliceToString(ids []uint) string { + if len(ids) == 0 { + return "[]" + } + idsStringSlice := make([]string, len(ids)) + for i, id := range ids { + idsStringSlice[i] = strconv.FormatUint(uint64(id), 10) + } + filter := "[" + strings.Join(idsStringSlice, ",") + "]" + return filter +} + +func determineSingleSemester(firstSemester model.Semester, lastSemester model.Semester, semesters []model.Semester) (bool, model.Semester) { + if semesters == nil { + if firstSemester.IsEqual(lastSemester) { + return true, firstSemester + } + return false, model.Semester{} + } + if len(semesters) == 1 { + return true, semesters[0] + } + return false, model.Semester{} +} + +// ToSearchCourseDTO converts Courses to slice of SearchCourseDTO +func ToSearchCourseDTO(cs ...model.Course) []SearchCourseDTO { + res := make([]SearchCourseDTO, len(cs)) + for i, c := range cs { + res[i] = SearchCourseDTO{ + Name: c.Name, + Slug: c.Slug, + Year: c.Year, + TeachingTerm: c.TeachingTerm, + } + } + return res +} + +// ToSearchStreamDTO converts Streams to slice of SearchStreamDTO +// +// Ignores any errors and sets affected fields to zero value +func ToSearchStreamDTO(wrapper dao.DaoWrapper, streams ...model.Stream) []SearchStreamDTO { + res := make([]SearchStreamDTO, len(streams)) + for i, s := range streams { + var courseName, teachingTerm, slug string + var year int + c, err := wrapper.GetCourseById(context.Background(), s.CourseID) + if err == nil { + courseName = c.Name + teachingTerm = c.TeachingTerm + slug = c.Slug + year = c.Year + } + res[i] = SearchStreamDTO{ + ID: s.ID, + Name: s.Name, + Description: s.Description, + CourseName: courseName, + Year: year, + TeachingTerm: teachingTerm, + CourseSlug: slug, + } + } + return res +} + +// ToSearchSubtitleDTO converts MeiliSubtitles to slice of SearchSubtitlesDTO +// +// Ignores any errors and sets affected fields to zero value +func ToSearchSubtitleDTO(wrapper dao.DaoWrapper, subtitles ...tools.MeiliSubtitles) []SearchSubtitlesDTO { + res := make([]SearchSubtitlesDTO, len(subtitles)) + for i, subtitle := range subtitles { + var streamName, courseName, slug, teachingTerm string + var year int + var startTime, endTime time.Time + s, err := wrapper.GetStreamByID(context.Background(), strconv.FormatUint(uint64(subtitle.StreamID), 10)) + if err == nil { + c, err := wrapper.GetCourseById(context.Background(), s.CourseID) + if err == nil { + streamName = s.Name + startTime = s.Start + endTime = s.End + courseName = c.Name + teachingTerm = c.TeachingTerm + slug = c.Slug + year = c.Year + } + } + res[i] = SearchSubtitlesDTO{ + StreamID: subtitle.StreamID, + Timestamp: subtitle.Timestamp, + TextPrev: subtitle.TextPrev, + Text: subtitle.Text, + TextNext: subtitle.TextNext, + StreamName: streamName, + StreamStartTime: startTime, + StreamEndTime: endTime, + CourseName: courseName, + CourseSlug: slug, + CourseYear: year, + CourseTeachingTerm: teachingTerm, + } + } + return res } diff --git a/api/search_test.go b/api/search_test.go new file mode 100644 index 000000000..8ad559d0d --- /dev/null +++ b/api/search_test.go @@ -0,0 +1,442 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "github.com/TUM-Dev/gocast/dao" + "github.com/TUM-Dev/gocast/mock_dao" + "github.com/TUM-Dev/gocast/mock_tools" + "github.com/TUM-Dev/gocast/model" + "github.com/TUM-Dev/gocast/tools" + "github.com/TUM-Dev/gocast/tools/testutils" + "github.com/gin-gonic/gin" + "github.com/golang/mock/gomock" + "github.com/matthiasreumann/gomino" + "github.com/meilisearch/meilisearch-go" +) + +func SearchRouterWrapper(r *gin.Engine) { + configGinSearchRouter(r, dao.DaoWrapper{}, tools.NewMeiliSearchFunctions()) +} + +func TestSearchCoursesFunctionality(t *testing.T) { + gin.SetMode(gin.TestMode) + courseMock := getCoursesMock(t) + streamMock := getStreamMock(t) + wrapper := dao.DaoWrapper{CoursesDao: courseMock, StreamsDao: streamMock} + meiliSearchMock := getMeiliSearchMock(t, wrapper) + + t.Run("GET/api/search/courses", func(t *testing.T) { + url := "/api/search/courses" + + gomino.TestCases{ + "invalid semesters": { + Router: SearchRouterWrapper, + Url: fmt.Sprintf("%s?semester=203W,2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusBadRequest, + }, + "invalid semester range": { + Router: SearchRouterWrapper, + Url: fmt.Sprintf("%s?firstSemester=2045R&lastSemester=2046W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusBadRequest, + }, + "no user single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextUserNil)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.PublicCourse), + }, + }, + }. + Method(http.MethodGet). + Url(url). + Run(t, testutils.Equal) + }) +} + +func TestSearchFunctionality(t *testing.T) { + gin.SetMode(gin.TestMode) + courseMock := getCoursesMock(t) + streamMock := getStreamMock(t) + wrapper := dao.DaoWrapper{CoursesDao: courseMock, StreamsDao: streamMock} + meiliSearchMock := getMeiliSearchMock(t, wrapper) + + // these tests ensure that even if meilisearch returns courses/streams/subtitles that the user is not allowed to see, these will not be passed on to the client + t.Run("GET/api/search", func(t *testing.T) { + url := "/api/search" + + gomino.TestCases{ + "invalid semesters": { + Router: SearchRouterWrapper, + Url: fmt.Sprintf("%s?semester=203W,2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusBadRequest, + }, + "invalid semester range": { + Router: SearchRouterWrapper, + Url: fmt.Sprintf("%s?firstSemester=2045R&lastSemester=2046W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusBadRequest, + }, + "too many courses": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W,%s2024W,%s2024W,%s2024W", url, testutils.HiddenCourse.Slug, testutils.EnrolledCourse.Slug, testutils.LoggedinCourse.Slug, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusBadRequest, + }, + "all semesters search success": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextUserNil)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{"COURSES": ToSearchCourseDTO(testutils.PublicCourse)}, + }, + "no user single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextUserNil)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.PublicCourse), + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamPublicCourse), + }, + }, + "studentNoCourse single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextStudentNoCourseSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.LoggedinCourse, testutils.PublicCourse), + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + }, + }, + "studentAllCourses single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextStudentAllCoursesSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.EnrolledCourse, testutils.LoggedinCourse, testutils.PublicCourse), + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamEnrolledCourse, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + }, + }, + "lecturerNoCourse single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextLecturerNoCourseSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.LoggedinCourse, testutils.PublicCourse), + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + }, + }, + "lecturerAllCourses single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextLecturerAllCoursesSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.AllCoursesForSearchTests...), + "STREAMS": ToSearchStreamDTO(wrapper, testutils.AllStreamsForSearchTests...), + }, + }, + "admin single semester search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&semester=2024W", url), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "COURSES": ToSearchCourseDTO(testutils.AllCoursesForSearchTests...), + "STREAMS": ToSearchStreamDTO(wrapper, testutils.AllStreamsForSearchTests...), + }, + }, + + "no user loggedin course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.LoggedinCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextUserNil)), + ExpectedCode: http.StatusBadRequest, + }, + "studentNoCourse enrolled course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.EnrolledCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextStudentNoCourseSearch)), + ExpectedCode: http.StatusBadRequest, + }, + "lecturerNoCourse enrolled course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.EnrolledCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextLecturerNoCourseSearch)), + ExpectedCode: http.StatusBadRequest, + }, + "no user public and hidden course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W,%s2024W", url, testutils.HiddenCourse.Slug, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextUserNil)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamHiddenCourse, testutils.StreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamHiddenCourse, testutils.SubtitlesStreamPublicCourse), + }, + }, + "studentNoCourse public, loggedin and hidden course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W,%s2024W,%s2024W", url, testutils.HiddenCourse.Slug, testutils.LoggedinCourse.Slug, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextStudentNoCourseSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamHiddenCourse, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamHiddenCourse, testutils.SubtitlesStreamLoggedinCourse, testutils.SubtitlesStreamPublicCourse), + }, + }, + "lecturerNoCourse public, loggedin and hidden course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W,%s2024W,%s2024W", url, testutils.HiddenCourse.Slug, testutils.LoggedinCourse.Slug, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextLecturerNoCourseSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamHiddenCourse, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamHiddenCourse, testutils.SubtitlesStreamLoggedinCourse, testutils.SubtitlesStreamPublicCourse), + }, + }, + "lecturerAllCourses public, loggedin and enrolled course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W,%s2024W,%s2024W", url, testutils.EnrolledCourse.Slug, testutils.LoggedinCourse.Slug, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextLecturerAllCoursesSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamEnrolledCourse, testutils.PrivateStreamEnrolledCourse, testutils.StreamLoggedinCourse, + testutils.PrivateStreamLoggedinCourse, testutils.StreamPublicCourse, testutils.PrivateStreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamEnrolledCourse, testutils.SubtitlesPrivateStreamEnrolledCourse, + testutils.SubtitlesStreamLoggedinCourse, testutils.SubtitlesPrivateStreamLoggedinCourse, testutils.SubtitlesStreamPublicCourse, + testutils.SubtitlesPrivateStreamPublicCourse), + }, + }, + "admin public, loggedin and enrolled course search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, meiliSearchMock) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W,%s2024W,%s2024W", url, testutils.EnrolledCourse.Slug, testutils.LoggedinCourse.Slug, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamEnrolledCourse, testutils.PrivateStreamEnrolledCourse, testutils.StreamLoggedinCourse, + testutils.PrivateStreamLoggedinCourse, testutils.StreamPublicCourse, testutils.PrivateStreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamEnrolledCourse, testutils.SubtitlesPrivateStreamEnrolledCourse, + testutils.SubtitlesStreamLoggedinCourse, testutils.SubtitlesPrivateStreamLoggedinCourse, testutils.SubtitlesStreamPublicCourse, + testutils.SubtitlesPrivateStreamPublicCourse), + }, + }, + + "no user all courses search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, getMeiliSearchMockReturningEveryStreamAndSubtitle(t, wrapper)) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextEmpty)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamHiddenCourse, testutils.StreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamHiddenCourse, testutils.SubtitlesStreamPublicCourse), + }, + }, + "studentNoCourse all courses search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, getMeiliSearchMockReturningEveryStreamAndSubtitle(t, wrapper)) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextStudentNoCourseSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamHiddenCourse, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamHiddenCourse, testutils.SubtitlesStreamLoggedinCourse, testutils.SubtitlesStreamPublicCourse), + }, + }, + "lecturerNoCourse all courses search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, getMeiliSearchMockReturningEveryStreamAndSubtitle(t, wrapper)) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextLecturerNoCourseSearch)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.StreamHiddenCourse, testutils.StreamLoggedinCourse, testutils.StreamPublicCourse), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.SubtitlesStreamHiddenCourse, testutils.SubtitlesStreamLoggedinCourse, testutils.SubtitlesStreamPublicCourse), + }, + }, + "admin all courses search": { + Router: func(r *gin.Engine) { + configGinSearchRouter(r, wrapper, getMeiliSearchMockReturningEveryStreamAndSubtitle(t, wrapper)) + }, + Url: fmt.Sprintf("%s?q=testen&course=%s2024W", url, testutils.PublicCourse.Slug), + Middlewares: testutils.GetMiddlewares(tools.ErrorHandler, testutils.TUMLiveContext(testutils.TUMLiveContextAdmin)), + ExpectedCode: http.StatusOK, + ExpectedResponse: MeiliSearchMap{ + "STREAMS": ToSearchStreamDTO(wrapper, testutils.AllStreamsForSearchTests...), + "SUBTITLES": ToSearchSubtitleDTO(wrapper, testutils.AllSubtitlesForSearchTests...), + }, + }, + }. + Method(http.MethodGet). + Url(url). + Run(t, testutils.Equal) + }) +} + +func getCoursesMock(t *testing.T) *mock_dao.MockCoursesDao { + mock := mock_dao.NewMockCoursesDao(gomock.NewController(t)) + for _, course := range testutils.AllCoursesForSearchTests { + mock.EXPECT().GetCourseById(gomock.Any(), course.ID).Return(course, nil).AnyTimes() + mock.EXPECT().GetCourseBySlugYearAndTerm(gomock.Any(), course.Slug, course.TeachingTerm, course.Year).Return(course, nil).AnyTimes() + } + mock.EXPECT().GetCourseById(gomock.Any(), gomock.Any()).Return(model.Course{}, errors.New("whoops")).AnyTimes() + mock.EXPECT().GetCourseBySlugYearAndTerm(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(model.Course{}, errors.New("whoops")).AnyTimes() + return mock +} + +func getStreamMock(t *testing.T) *mock_dao.MockStreamsDao { + mock := mock_dao.NewMockStreamsDao(gomock.NewController(t)) + for _, s := range testutils.AllStreamsForSearchTests { + mock.EXPECT().GetStreamByID(gomock.Any(), strconv.Itoa(int(s.ID))).Return(s, nil).AnyTimes() + } + mock.EXPECT().GetStreamByID(gomock.Any(), gomock.Any()).Return(model.Stream{}, errors.New("whoops")).AnyTimes() + return mock +} + +func getMeiliSearchMock(t *testing.T, daoWrapper dao.DaoWrapper) *mock_tools.MockMeiliSearchInterface { + mock := mock_tools.NewMockMeiliSearchInterface(gomock.NewController(t)) + mock.EXPECT().Search(gomock.Any(), gomock.Any(), 6, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(q interface{}, limit interface{}, searchType interface{}, courseFilter string, streamFilter string, subtitleFilter string) *meilisearch.MultiSearchResponse { + streams, _ := tools.ToMeiliStreams(testutils.AllStreamsForSearchTests, daoWrapper) + return &meilisearch.MultiSearchResponse{Results: []meilisearch.SearchResponse{ + {IndexUID: "COURSES", Hits: meiliCourseSliceToInterfaceSlice(tools.ToMeiliCourses(testutils.AllCoursesForSearchTests))}, + {IndexUID: "STREAMS", Hits: meiliStreamSliceToInterfaceSlice(streams)}, + }} + }).AnyTimes() + + mock.EXPECT().Search(gomock.Any(), gomock.Any(), 4, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(q interface{}, limit interface{}, searchType interface{}, courseFilter string, streamFilter string, subtitleFilter string) *meilisearch.MultiSearchResponse { + return &meilisearch.MultiSearchResponse{Results: []meilisearch.SearchResponse{ + {IndexUID: "COURSES", Hits: meiliCourseSliceToInterfaceSlice(tools.ToMeiliCourses(testutils.AllCoursesForSearchTests))}, + }} + }).AnyTimes() + + mock.EXPECT().Search(gomock.Any(), gomock.Any(), 3, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(q interface{}, limit interface{}, searchType interface{}, courseFilter string, streamFilter string, subtitleFilter string) *meilisearch.MultiSearchResponse { + streams := make([]model.Stream, 0) + subtitles := make([]tools.MeiliSubtitles, 0) + + // find indexes for id arrays + s := regexp.MustCompile(`\[`) + c := regexp.MustCompile(`]`) + startIndexes := s.FindAllIndex([]byte(streamFilter), -1) + endIndexes := c.FindAllIndex([]byte(streamFilter), -1) + + for i, startIndex := range startIndexes { + idsAsStrings := strings.Split(streamFilter[startIndex[1]:endIndexes[i][0]], ",") + for _, idString := range idsAsStrings { + id, _ := strconv.Atoi(idString) + for _, stream := range testutils.AllStreamsForSearchTests { + if stream.CourseID == uint(id) { + streams = append(streams, stream) + } + } + } + } + + for _, subtitle := range testutils.AllSubtitlesForSearchTests { + if slices.ContainsFunc(streams, func(stream model.Stream) bool { + return stream.ID == subtitle.StreamID + }) { + subtitles = append(subtitles, subtitle) + } + } + returnStreams, _ := tools.ToMeiliStreams(streams, daoWrapper) + return &meilisearch.MultiSearchResponse{Results: []meilisearch.SearchResponse{ + {IndexUID: "STREAMS", Hits: meiliStreamSliceToInterfaceSlice(returnStreams)}, + {IndexUID: "SUBTITLES", Hits: meiliSubtitleSliceToInterfaceSlice(subtitles)}, + }} + }).AnyTimes() + return mock +} + +func getMeiliSearchMockReturningEveryStreamAndSubtitle(t *testing.T, daoWrapper dao.DaoWrapper) *mock_tools.MockMeiliSearchInterface { + mock := mock_tools.NewMockMeiliSearchInterface(gomock.NewController(t)) + mock.EXPECT().Search(gomock.Any(), gomock.Any(), 3, gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(q interface{}, limit interface{}, searchType interface{}, courseFilter string, streamFilter string, subtitleFilter string) *meilisearch.MultiSearchResponse { + streams, _ := tools.ToMeiliStreams(testutils.AllStreamsForSearchTests, daoWrapper) + return &meilisearch.MultiSearchResponse{Results: []meilisearch.SearchResponse{ + {IndexUID: "STREAMS", Hits: meiliStreamSliceToInterfaceSlice(streams)}, + {IndexUID: "SUBTITLES", Hits: meiliSubtitleSliceToInterfaceSlice(testutils.AllSubtitlesForSearchTests)}, + }} + }).AnyTimes() + return mock +} + +func meiliCourseSliceToInterfaceSlice(cs []tools.MeiliCourse) []interface{} { + s := make([]interface{}, len(cs)) + for i, c := range cs { + s[i] = c + } + return s +} + +func meiliStreamSliceToInterfaceSlice(cs []tools.MeiliStream) []interface{} { + s := make([]interface{}, len(cs)) + for i, c := range cs { + s[i] = c + } + return s +} + +func meiliSubtitleSliceToInterfaceSlice(cs []tools.MeiliSubtitles) []interface{} { + s := make([]interface{}, len(cs)) + for i, c := range cs { + s[i] = c + } + return s +} diff --git a/dao/courses.go b/dao/courses.go index a03557700..18f910b44 100644 --- a/dao/courses.go +++ b/dao/courses.go @@ -32,9 +32,11 @@ type CoursesDao interface { GetCourseBySlugYearAndTerm(ctx context.Context, slug string, term string, year int) (model.Course, error) // GetAllCoursesWithTUMIDFromSemester returns all courses with a non-null tum_identifier from a given semester or later GetAllCoursesWithTUMIDFromSemester(ctx context.Context, year int, term string) (courses []model.Course, err error) - GetAvailableSemesters(c context.Context) []Semester + GetAvailableSemesters(c context.Context) []model.Semester GetCourseByShortLink(link string) (model.Course, error) GetCourseAdmins(courseID uint) ([]model.User, error) + // ExecAllCourses executes f on all courses from database without batching + ExecAllCourses(f func([]Course)) UpdateCourse(ctx context.Context, course model.Course) error UpdateCourseMetadata(ctx context.Context, course model.Course) @@ -243,11 +245,11 @@ func (d coursesDao) GetAllCoursesWithTUMIDFromSemester(ctx context.Context, year return foundCourses, err } -func (d coursesDao) GetAvailableSemesters(c context.Context) []Semester { +func (d coursesDao) GetAvailableSemesters(c context.Context) []model.Semester { if cached, found := Cache.Get("getAllSemesters"); found { - return cached.([]Semester) + return cached.([]model.Semester) } else { - var semesters []Semester + var semesters []model.Semester DB.Raw("SELECT year, teaching_term from courses " + "group by year, teaching_term " + "order by year desc, teaching_term desc").Scan(&semesters) @@ -281,6 +283,26 @@ func (d coursesDao) GetCourseAdmins(courseID uint) ([]model.User, error) { return admins, err } +type Course struct { + Name, Slug, TeachingTerm, Visibility string + ID uint + Year int +} + +// ExecAllCourses executes f on all courses. +// +// loads every course into memory +func (d coursesDao) ExecAllCourses(f func([]Course)) { + var res []Course + err := DB.Raw(`SELECT id, name, slug, year, teaching_term, visibility + FROM courses + WHERE deleted_at IS NULL ORDER BY id`).Scan(&res).Error + if err != nil { + fmt.Println(err) + } + f(res) +} + func (d coursesDao) UpdateCourse(ctx context.Context, course model.Course) error { defer Cache.Clear() return DB.Session(&gorm.Session{FullSaveAssociations: true}).Updates(&course).Error @@ -316,8 +338,3 @@ func (d coursesDao) DeleteCourse(course model.Course) { logger.Error("Can't delete course", "err", err) } } - -type Semester struct { - TeachingTerm string - Year int -} diff --git a/dao/streams.go b/dao/streams.go index 92bfcaf75..0ec34a8ba 100755 --- a/dao/streams.go +++ b/dao/streams.go @@ -30,7 +30,7 @@ type StreamsDao interface { GetStreamByID(ctx context.Context, id string) (stream model.Stream, err error) GetWorkersForStream(stream model.Stream) ([]model.Worker, error) GetAllStreams() ([]model.Stream, error) - ExecAllStreamsWithCoursesAndSubtitles(f func([]StreamWithCourseAndSubtitles)) + ExecAllStreamsWithCoursesAndSubtitlesBatched(f func([]StreamWithCourseAndSubtitles)) GetCurrentLive(ctx context.Context) (currentLive []model.Stream, err error) GetCurrentLiveNonHidden(ctx context.Context) (currentLive []model.Stream, err error) GetLiveStreamsInLectureHall(lectureHallId uint) ([]model.Stream, error) @@ -216,39 +216,46 @@ func (d streamsDao) GetAllStreams() ([]model.Stream, error) { } type StreamWithCourseAndSubtitles struct { - Name, Description, TeachingTerm, CourseName, Subtitles string - ID, CourseID uint - Year int + Name, Description, TeachingTerm, CourseName, Subtitles, Visibility string + ID, CourseID, Private uint + Year int } -// ExecAllStreamsWithCoursesAndSubtitles executes f on all streams with their courses and subtitles preloaded. -func (d streamsDao) ExecAllStreamsWithCoursesAndSubtitles(f func([]StreamWithCourseAndSubtitles)) { +// ExecAllStreamsWithCoursesAndSubtitlesBatched executes f on all streams (batched) with their courses and subtitles preloaded. +func (d streamsDao) ExecAllStreamsWithCoursesAndSubtitlesBatched(f func([]StreamWithCourseAndSubtitles)) { var res []StreamWithCourseAndSubtitles batchNum := 0 batchSize := 100 var numStreams int64 DB.Where("recording").Model(&model.Stream{}).Count(&numStreams) + lastSeenId := uint(0) for batchSize*batchNum < int(numStreams) { err := DB.Raw(`WITH sws AS ( SELECT streams.id, streams.name, streams.description, + streams.private as private, c.id as course_id, c.name as course_name, c.teaching_term, c.year, + c.visibility as visibility, s.content as subtitles, IFNULL(s.stream_id, streams.id) as sid FROM streams JOIN courses c ON c.id = streams.course_id LEFT JOIN subtitles s ON streams.id = s.stream_id - WHERE streams.recording AND streams.deleted_at IS NULL - LIMIT ? OFFSET ? + WHERE streams.recording AND streams.deleted_at IS NULL AND streams.id > ? + ORDER BY streams.id ASC + LIMIT ? ) - SELECT *, GROUP_CONCAT(subtitles, '\n') AS subtitles FROM sws GROUP BY sid;`, batchSize, batchNum*batchSize).Scan(&res).Error + SELECT *, GROUP_CONCAT(subtitles, '\n') AS subtitles FROM sws GROUP BY sid ORDER BY sid;`, lastSeenId, batchSize).Scan(&res).Error if err != nil { fmt.Println(err) } + if err == nil { + lastSeenId = res[len(res)-1].ID + } f(res) batchNum++ } diff --git a/mock_dao/courses.go b/mock_dao/courses.go index 069d1332c..5700234b1 100644 --- a/mock_dao/courses.go +++ b/mock_dao/courses.go @@ -76,6 +76,18 @@ func (mr *MockCoursesDaoMockRecorder) DeleteCourse(course interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCourse", reflect.TypeOf((*MockCoursesDao)(nil).DeleteCourse), course) } +// ExecAllCourses mocks base method. +func (m *MockCoursesDao) ExecAllCourses(f func([]dao.Course)) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ExecAllCourses", f) +} + +// ExecAllCourses indicates an expected call of ExecAllCourses. +func (mr *MockCoursesDaoMockRecorder) ExecAllCourses(f interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecAllCourses", reflect.TypeOf((*MockCoursesDao)(nil).ExecAllCourses), f) +} + // GetAdministeredCoursesByUserId mocks base method. func (m *MockCoursesDao) GetAdministeredCoursesByUserId(ctx context.Context, userid uint, teachingTerm string, year int) ([]model.Course, error) { m.ctrl.T.Helper() @@ -136,10 +148,10 @@ func (mr *MockCoursesDaoMockRecorder) GetAllCoursesWithTUMIDFromSemester(ctx, ye } // GetAvailableSemesters mocks base method. -func (m *MockCoursesDao) GetAvailableSemesters(c context.Context) []dao.Semester { +func (m *MockCoursesDao) GetAvailableSemesters(c context.Context) []model.Semester { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAvailableSemesters", c) - ret0, _ := ret[0].([]dao.Semester) + ret0, _ := ret[0].([]model.Semester) return ret0 } diff --git a/mock_dao/streams.go b/mock_dao/streams.go index 1a92479b7..378ed544c 100644 --- a/mock_dao/streams.go +++ b/mock_dao/streams.go @@ -143,16 +143,16 @@ func (mr *MockStreamsDaoMockRecorder) DeleteUnit(id interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUnit", reflect.TypeOf((*MockStreamsDao)(nil).DeleteUnit), id) } -// ExecAllStreamsWithCoursesAndSubtitles mocks base method. -func (m *MockStreamsDao) ExecAllStreamsWithCoursesAndSubtitles(f func([]dao.StreamWithCourseAndSubtitles)) { +// ExecAllStreamsWithCoursesAndSubtitlesBatched mocks base method. +func (m *MockStreamsDao) ExecAllStreamsWithCoursesAndSubtitlesBatched(f func([]dao.StreamWithCourseAndSubtitles)) { m.ctrl.T.Helper() - m.ctrl.Call(m, "ExecAllStreamsWithCoursesAndSubtitles", f) + m.ctrl.Call(m, "ExecAllStreamsWithCoursesAndSubtitlesBatched", f) } -// ExecAllStreamsWithCoursesAndSubtitles indicates an expected call of ExecAllStreamsWithCoursesAndSubtitles. -func (mr *MockStreamsDaoMockRecorder) ExecAllStreamsWithCoursesAndSubtitles(f interface{}) *gomock.Call { +// ExecAllStreamsWithCoursesAndSubtitlesBatched indicates an expected call of ExecAllStreamsWithCoursesAndSubtitlesBatched. +func (mr *MockStreamsDaoMockRecorder) ExecAllStreamsWithCoursesAndSubtitlesBatched(f interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecAllStreamsWithCoursesAndSubtitles", reflect.TypeOf((*MockStreamsDao)(nil).ExecAllStreamsWithCoursesAndSubtitles), f) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecAllStreamsWithCoursesAndSubtitlesBatched", reflect.TypeOf((*MockStreamsDao)(nil).ExecAllStreamsWithCoursesAndSubtitlesBatched), f) } // GetAllStreams mocks base method. diff --git a/mock_tools/meiliSearch.go b/mock_tools/meiliSearch.go new file mode 100644 index 000000000..ebb4c24b0 --- /dev/null +++ b/mock_tools/meiliSearch.go @@ -0,0 +1,63 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: meiliSearch.go + +// Package mock_tools is a generated GoMock package. +package mock_tools + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + meilisearch "github.com/meilisearch/meilisearch-go" +) + +// MockMeiliSearchInterface is a mock of MeiliSearchInterface interface. +type MockMeiliSearchInterface struct { + ctrl *gomock.Controller + recorder *MockMeiliSearchInterfaceMockRecorder +} + +// MockMeiliSearchInterfaceMockRecorder is the mock recorder for MockMeiliSearchInterface. +type MockMeiliSearchInterfaceMockRecorder struct { + mock *MockMeiliSearchInterface +} + +// NewMockMeiliSearchInterface creates a new mock instance. +func NewMockMeiliSearchInterface(ctrl *gomock.Controller) *MockMeiliSearchInterface { + mock := &MockMeiliSearchInterface{ctrl: ctrl} + mock.recorder = &MockMeiliSearchInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMeiliSearchInterface) EXPECT() *MockMeiliSearchInterfaceMockRecorder { + return m.recorder +} + +// Search mocks base method. +func (m *MockMeiliSearchInterface) Search(q string, limit int64, searchType int, courseFilter, streamFilter, subtitleFilter string) *meilisearch.MultiSearchResponse { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", q, limit, searchType, courseFilter, streamFilter, subtitleFilter) + ret0, _ := ret[0].(*meilisearch.MultiSearchResponse) + return ret0 +} + +// Search indicates an expected call of Search. +func (mr *MockMeiliSearchInterfaceMockRecorder) Search(q, limit, searchType, courseFilter, streamFilter, subtitleFilter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockMeiliSearchInterface)(nil).Search), q, limit, searchType, courseFilter, streamFilter, subtitleFilter) +} + +// SearchSubtitles mocks base method. +func (m *MockMeiliSearchInterface) SearchSubtitles(q string, streamID uint) *meilisearch.SearchResponse { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchSubtitles", q, streamID) + ret0, _ := ret[0].(*meilisearch.SearchResponse) + return ret0 +} + +// SearchSubtitles indicates an expected call of SearchSubtitles. +func (mr *MockMeiliSearchInterfaceMockRecorder) SearchSubtitles(q, streamID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchSubtitles", reflect.TypeOf((*MockMeiliSearchInterface)(nil).SearchSubtitles), q, streamID) +} diff --git a/model/semester.go b/model/semester.go new file mode 100644 index 000000000..2e011e93b --- /dev/null +++ b/model/semester.go @@ -0,0 +1,34 @@ +package model + +type Semester struct { + TeachingTerm string + Year int +} + +// IsInRangeOfSemesters checks if s is element of semesters slice +func (s *Semester) IsInRangeOfSemesters(semesters []Semester) bool { + for _, semester := range semesters { + if s.Year == semester.Year && s.TeachingTerm == semester.TeachingTerm { + return true + } + } + return false +} + +// IsBetweenSemesters checks if s is between firstSemester (inclusive) and lastSemester (inclusive) +func (s *Semester) IsBetweenSemesters(firstSemester Semester, lastSemester Semester) bool { + if firstSemester.Year == lastSemester.Year && firstSemester.TeachingTerm == lastSemester.TeachingTerm { + return s.Year == firstSemester.Year && s.TeachingTerm == firstSemester.TeachingTerm + } + return s.IsGreaterEqualThan(firstSemester) && lastSemester.IsGreaterEqualThan(*s) +} + +// IsEqual checks if s is equal to otherSemester +func (s *Semester) IsEqual(otherSemester Semester) bool { + return s.Year == otherSemester.Year && s.TeachingTerm == otherSemester.TeachingTerm +} + +// IsGreaterEqualThan checks if s comes after or is equal to s1 +func (s *Semester) IsGreaterEqualThan(s1 Semester) bool { + return s.Year > s1.Year || (s.Year == s1.Year && (s.TeachingTerm == "W" || s1.TeachingTerm == "S")) +} diff --git a/model/user.go b/model/user.go index 04be99a44..408296b21 100755 --- a/model/user.go +++ b/model/user.go @@ -71,7 +71,7 @@ type UserSetting struct { } // GetPreferredName returns the preferred name of the user if set, otherwise the firstName from TUMOnline -func (u User) GetPreferredName() string { +func (u *User) GetPreferredName() string { for _, setting := range u.Settings { if setting.Type == PreferredName { return setting.Value @@ -158,7 +158,7 @@ func (u *User) GetCustomSpeeds() (speeds CustomSpeeds) { } // GetPreferredGreeting returns the preferred greeting of the user if set, otherwise Moin -func (u User) GetPreferredGreeting() string { +func (u *User) GetPreferredGreeting() string { for _, setting := range u.Settings { if setting.Type == Greeting { return setting.Value @@ -190,7 +190,7 @@ func (u *User) GetSeekingTime() int { } // PreferredNameChangeAllowed returns false if the user has set a preferred name within the last 3 months, otherwise true -func (u User) PreferredNameChangeAllowed() bool { +func (u *User) PreferredNameChangeAllowed() bool { for _, setting := range u.Settings { if setting.Type == PreferredName && time.Since(setting.UpdatedAt) < time.Hour*24*30*3 { return false @@ -205,7 +205,7 @@ type AutoSkipSetting struct { } // GetAutoSkipEnabled returns whether the user has enabled auto skip -func (u User) GetAutoSkipEnabled() (AutoSkipSetting, error) { +func (u *User) GetAutoSkipEnabled() (AutoSkipSetting, error) { for _, setting := range u.Settings { if setting.Type == AutoSkip { var a AutoSkipSetting @@ -262,8 +262,12 @@ func (u *User) IsAdminOfCourse(course Course) bool { return u.Role == AdminType || course.UserID == u.ID } +// IsEligibleToWatchCourse checks if the user is allowed to access the course func (u *User) IsEligibleToWatchCourse(course Course) bool { - if course.Visibility == "loggedin" || course.Visibility == "public" { + if u == nil { + return course.Visibility == "public" || course.Visibility == "hidden" + } + if course.Visibility == "public" || course.Visibility == "hidden" || course.Visibility == "loggedin" { return true } for _, invCourse := range u.Courses { @@ -274,6 +278,11 @@ func (u *User) IsEligibleToWatchCourse(course Course) bool { return u.IsAdminOfCourse(course) } +// IsEligibleToSearchForCourse is a stricter version of IsEligibleToWatchCourse; in case of hidden course, it returns true only when the user is an admin of the course +func (u *User) IsEligibleToSearchForCourse(course Course) bool { + return u.IsEligibleToWatchCourse(course) && course.Visibility != "hidden" || u.IsAdminOfCourse(course) +} + func (u *User) CoursesForSemester(year int, term string, context context.Context) []Course { cMap := make(map[uint]Course) for _, c := range u.Courses { @@ -293,6 +302,58 @@ func (u *User) CoursesForSemester(year int, term string, context context.Context return cRes } +// AdministeredCoursesForSemesters returns all courses, that the user is a course admin of, in the given semester range or semesters +func (u *User) AdministeredCoursesForSemesters(semesters []Semester) []Course { + var semester Semester + administeredCourses := make([]Course, 0) + for _, c := range u.AdministeredCourses { + semester = Semester{TeachingTerm: c.TeachingTerm, Year: c.Year} + if semester.IsInRangeOfSemesters(semesters) { + administeredCourses = append(administeredCourses, c) + } + } + return administeredCourses +} + +// AdministeredCoursesBetweenSemesters returns all courses, that the user is a course admin of, between firstSemester and lasSemester +func (u *User) AdministeredCoursesBetweenSemesters(firstSemester Semester, lastSemester Semester) []Course { + var semester Semester + administeredCourses := make([]Course, 0) + for _, c := range u.AdministeredCourses { + semester = Semester{TeachingTerm: c.TeachingTerm, Year: c.Year} + if semester.IsBetweenSemesters(firstSemester, lastSemester) { + administeredCourses = append(administeredCourses, c) + } + } + return administeredCourses +} + +// CoursesForSemestersWithoutAdministeredCourses returns all courses of the user in the given semester range or semesters excluding administered courses +func (u *User) CoursesForSemestersWithoutAdministeredCourses(semesters []Semester) []Course { + var semester Semester + courses := make([]Course, 0) + for _, c := range u.Courses { + semester = Semester{TeachingTerm: c.TeachingTerm, Year: c.Year} + if semester.IsInRangeOfSemesters(semesters) && !u.IsAdminOfCourse(c) { + courses = append(courses, c) + } + } + return courses +} + +// CoursesBetweenSemestersWithoutAdministeredCourses returns all courses of the user in the given semester range or semesters excluding administered courses +func (u *User) CoursesBetweenSemestersWithoutAdministeredCourses(firstSemester Semester, lastSemester Semester) []Course { + var semester Semester + courses := make([]Course, 0) + for _, c := range u.Courses { + semester = Semester{TeachingTerm: c.TeachingTerm, Year: c.Year} + if semester.IsBetweenSemesters(firstSemester, lastSemester) && !u.IsAdminOfCourse(c) { + courses = append(courses, c) + } + } + return courses +} + var ( ErrInvalidHash = errors.New("the encoded hash is not in the correct format") ErrIncompatibleVersion = errors.New("incompatible version of argon2") diff --git a/tools/meiliExporter.go b/tools/meiliExporter.go index b3906fd93..606f0f68a 100644 --- a/tools/meiliExporter.go +++ b/tools/meiliExporter.go @@ -1,10 +1,13 @@ package tools import ( + "context" "errors" "fmt" "strings" + "github.com/TUM-Dev/gocast/model" + "github.com/TUM-Dev/gocast/dao" "github.com/asticode/go-astisub" "github.com/meilisearch/meilisearch-go" @@ -18,6 +21,8 @@ type MeiliStream struct { Year int `json:"year"` TeachingTerm string `json:"semester"` CourseID uint `json:"courseID"` + Private uint `json:"private"` + Visibility string `json:"visibility"` // corresponds to the visibility of the course } type MeiliSubtitles struct { @@ -29,6 +34,15 @@ type MeiliSubtitles struct { TextNext string `json:"textNext"` // the next subtitle line } +type MeiliCourse struct { + ID uint `json:"ID"` + Name string `json:"name"` + Slug string `json:"slug"` + Year int `json:"year"` + TeachingTerm string `json:"semester"` + Visibility string `json:"visibility"` +} + type MeiliExporter struct { c *meilisearch.Client d dao.DaoWrapper @@ -46,21 +60,24 @@ func NewMeiliExporter(d dao.DaoWrapper) *MeiliExporter { return &MeiliExporter{c, d} } +// Export exports all relevant search data to MeiliSearch Instance func (m *MeiliExporter) Export() { if m == nil { return } index := m.c.Index("STREAMS") - _, err := m.c.Index("SUBTITLES").DeleteAllDocuments() + _, err := index.DeleteAllDocuments() + if err != nil { + logger.Warn("could not delete all old streams", "err", err) + } + _, err = m.c.Index("SUBTITLES").DeleteAllDocuments() if err != nil { logger.Warn("could not delete all old subtitles", "err", err) } - m.d.StreamsDao.ExecAllStreamsWithCoursesAndSubtitles(func(streams []dao.StreamWithCourseAndSubtitles) { + m.d.StreamsDao.ExecAllStreamsWithCoursesAndSubtitlesBatched(func(streams []dao.StreamWithCourseAndSubtitles) { meilistreams := make([]MeiliStream, len(streams)) - streamIDs := make([]uint, len(streams)) for i, stream := range streams { - streamIDs[i] = stream.ID meilistreams[i] = MeiliStream{ ID: stream.ID, CourseID: stream.CourseID, @@ -69,6 +86,8 @@ func (m *MeiliExporter) Export() { CourseName: stream.CourseName, Year: stream.Year, TeachingTerm: stream.TeachingTerm, + Visibility: stream.Visibility, + Private: stream.Private, } if stream.Subtitles != "" { meiliSubtitles := make([]MeiliSubtitles, 0) @@ -106,6 +125,30 @@ func (m *MeiliExporter) Export() { logger.Error("issue adding documents to meili", "err", err) } }) + + coursesIndex := m.c.Index("COURSES") + _, err = coursesIndex.DeleteAllDocuments() + if err != nil { + logger.Warn("could not delete all old courses", "err", err) + } + + m.d.CoursesDao.ExecAllCourses(func(courses []dao.Course) { + meilicourses := make([]MeiliCourse, len(courses)) + for i, course := range courses { + meilicourses[i] = MeiliCourse{ + ID: course.ID, + Name: course.Name, + Slug: course.Slug, + Year: course.Year, + TeachingTerm: course.TeachingTerm, + Visibility: course.Visibility, + } + } + _, err := coursesIndex.AddDocumentsInBatches(meilicourses, 500, "ID") + if err != nil { + logger.Error("issue adding courses to meili", "err", err) + } + }) } func (m *MeiliExporter) SetIndexSettings() { @@ -117,7 +160,14 @@ func (m *MeiliExporter) SetIndexSettings() { "W": {"Wintersemester", "Winter", "WS", "WiSe"}, "S": {"Sommersemester", "Sommer", "SS", "SoSe", "Summer"}, } - _, err := index.UpdateSynonyms(&synonyms) + _, err := m.c.Index("STREAMS").UpdateSettings(&meilisearch.Settings{ + FilterableAttributes: []string{"courseID", "year", "semester", "visibility", "private"}, + SearchableAttributes: []string{"name", "description"}, + }) + if err != nil { + logger.Warn("could not set settings for meili index STREAMS", "err", err) + } + _, err = index.UpdateSynonyms(&synonyms) if err != nil { logger.Error("could not set synonyms for meili index STREAMS", "err", err) } @@ -130,4 +180,57 @@ func (m *MeiliExporter) SetIndexSettings() { if err != nil { logger.Warn("could not set settings for meili index SUBTITLES", "err", err) } + + _, err = m.c.Index("COURSES").UpdateSettings(&meilisearch.Settings{ + FilterableAttributes: []string{"ID", "visibility", "year", "semester"}, + SearchableAttributes: []string{"slug", "name"}, + SortableAttributes: []string{"year", "semester"}, + }) + if err != nil { + logger.Warn("could not set settings for meili index COURSES", "err", err) + } +} + +// ToMeiliCourses converts slice of model.Course to slice of MeiliCourse +func ToMeiliCourses(cs []model.Course) []MeiliCourse { + res := make([]MeiliCourse, len(cs)) + for i, c := range cs { + res[i] = MeiliCourse{ + ID: c.ID, + Name: c.Name, + Slug: c.Slug, + Year: c.Year, + TeachingTerm: c.TeachingTerm, + Visibility: c.Visibility, + } + } + return res +} + +// ToMeiliStreams converts slice of model.Stream to slice of MeiliStream +func ToMeiliStreams(streams []model.Stream, daoWrapper dao.DaoWrapper) ([]MeiliStream, error) { + res := make([]MeiliStream, len(streams)) + for i, s := range streams { + c, err := daoWrapper.GetCourseById(context.Background(), s.CourseID) + if err != nil { + return nil, err + } + var private uint + if s.Private { + private = 1 + } + + res[i] = MeiliStream{ + ID: s.ID, + Name: s.Name, + Description: s.Description, + CourseName: c.Name, + Year: c.Year, + TeachingTerm: c.TeachingTerm, + CourseID: s.CourseID, + Private: private, + Visibility: c.Visibility, + } + } + return res, nil } diff --git a/tools/meiliSearch.go b/tools/meiliSearch.go index ae86717b3..b30636f65 100644 --- a/tools/meiliSearch.go +++ b/tools/meiliSearch.go @@ -6,7 +6,20 @@ import ( "github.com/meilisearch/meilisearch-go" ) -func SearchSubtitles(q string, streamID uint) *meilisearch.SearchResponse { +//go:generate mockgen -source=meiliSearch.go -destination ../mock_tools/meiliSearch.go + +type MeiliSearchInterface interface { + SearchSubtitles(q string, streamID uint) *meilisearch.SearchResponse + Search(q string, limit int64, searchType int, courseFilter string, streamFilter string, subtitleFilter string) *meilisearch.MultiSearchResponse +} + +type meiliSearchFunctions struct{} + +func NewMeiliSearchFunctions() MeiliSearchInterface { + return &meiliSearchFunctions{} +} + +func (d *meiliSearchFunctions) SearchSubtitles(q string, streamID uint) *meilisearch.SearchResponse { c, err := Cfg.GetMeiliClient() if err != nil { return nil @@ -21,3 +34,89 @@ func SearchSubtitles(q string, streamID uint) *meilisearch.SearchResponse { } return response } + +func getCourseWideSubtitleSearchRequest(q string, limit int64, streamFilter string) meilisearch.SearchRequest { + req := meilisearch.SearchRequest{ + IndexUID: "SUBTITLES", + Query: q, + Limit: limit, + Filter: streamFilter, + AttributesToRetrieve: []string{"streamID", "timestamp", "textPrev", "text", "textNext"}, + } + return req +} + +func getStreamsSearchRequest(q string, limit int64, streamFilter string) meilisearch.SearchRequest { + req := meilisearch.SearchRequest{ + IndexUID: "STREAMS", + Query: q, + Limit: limit + 2, + Filter: streamFilter, + AttributesToRetrieve: []string{"ID", "name", "description", "courseName", "year", "semester"}, + } + return req +} + +func getCoursesSearchRequest(q string, limit int64, courseFilter string) meilisearch.SearchRequest { + req := meilisearch.SearchRequest{ + IndexUID: "COURSES", + Query: q, + Limit: limit + 2, + Filter: courseFilter, + AttributesToRetrieve: []string{"name", "slug", "year", "semester"}, + } + return req +} + +// Search passes search requests on to MeiliSearch instance and returns the results +// +// searchType specifies bit-wise which indexes should be searched (lowest bit set to 1: Index SUBTITLES | second-lowest bit set to 1: Index STREAMS | third-lowest bit set to 1: Index COURSES) +func (d *meiliSearchFunctions) Search(q string, limit int64, searchType int, courseFilter string, streamFilter string, subtitleFilter string) *meilisearch.MultiSearchResponse { + c, err := Cfg.GetMeiliClient() + if err != nil { + return nil + } + + bitOperator := 1 + var reqs []meilisearch.SearchRequest + + for i := 0; i < 4; i++ { + switch searchType & bitOperator { + case 0: + break + case 1: + reqs = append(reqs, getCourseWideSubtitleSearchRequest(q, limit, subtitleFilter)) + case 2: + reqs = append(reqs, getStreamsSearchRequest(q, limit, streamFilter)) + case 4: + reqs = append(reqs, getCoursesSearchRequest(q, limit, courseFilter)) + } + bitOperator <<= 1 + } + + // multisearch Request + response, err := c.MultiSearch(&meilisearch.MultiSearchRequest{Queries: reqs}) + if err != nil { + logger.Error("could not search in meili", "err", err) + return nil + } + return response +} + +func SearchCourses(q string, filter string) *meilisearch.SearchResponse { + c, err := Cfg.GetMeiliClient() + if err != nil { + return nil + } + + response, err := c.Index("COURSES").Search(q, &meilisearch.SearchRequest{ + Filter: filter, + Limit: 10, + AttributesToRetrieve: []string{"name", "slug", "year", "semester"}, + }) + if err != nil { + logger.Error("could not search courses in meili", "err", err) + return nil + } + return response +} diff --git a/tools/testutils/testdata.go b/tools/testutils/testdata.go index 6fc92b2cd..f0f5a106c 100644 --- a/tools/testutils/testdata.go +++ b/tools/testutils/testdata.go @@ -289,6 +289,226 @@ var ( } ) +// testdata for testing the search functionality +// ids +var ( + HiddenCourseID = uint(400) + EnrolledCourseID = uint(401) + LoggedinCourseID = uint(402) + PublicCourseID = uint(403) + + StreamIDHiddenCourse = uint(500) + StreamIDEnrolledCourse = uint(510) + StreamIDLoggedinCourse = uint(520) + StreamIDPublicCourse = uint(530) + PrivateStreamIDHiddenCourse = uint(501) + PrivateStreamIDEnrolledCourse = uint(511) + PrivateStreamIDLoggedinCourse = uint(521) + PrivateStreamIDPublicCourse = uint(531) + + SubtitlesIDPublicCourseStream = "1000" + SubtitlesIDPublicCoursePrivateStream = "1001" + SubtitlesIDLoggedinCourseStream = "1002" + SubtitlesIDLoggedinCoursePrivateStream = "1003" + SubtitlesIDEnrolledCourseStream = "1004" + SubtitlesIDEnrolledCoursePrivateStream = "1005" + SubtitlesIDHiddenCourseStream = "1006" + SubtitlesIDHiddenCoursePrivateStream = "1007" +) + +// TUMLiveContext for search tests +var ( + TUMLiveContextLecturerNoCourseSearch = tools.TUMLiveContext{User: &LecturerNoCourse} + TUMLiveContextLecturerAllCoursesSearch = tools.TUMLiveContext{User: &LecturerAllCourses} + TUMLiveContextStudentNoCourseSearch = tools.TUMLiveContext{User: &StudentNoCourse} + TUMLiveContextStudentAllCoursesSearch = tools.TUMLiveContext{User: &StudentAllCourses} +) + +var ( + // users + LecturerNoCourse = model.User{ + Model: gorm.Model{ID: 610}, + Role: model.LecturerType, + Courses: []model.Course{}, + AdministeredCourses: []model.Course{}, + } + LecturerAllCourses = model.User{ + Model: gorm.Model{ID: 611}, + Role: model.LecturerType, + Courses: []model.Course{}, + AdministeredCourses: []model.Course{HiddenCourse, EnrolledCourse, LoggedinCourse, PublicCourse}, + } + StudentNoCourse = model.User{ + Model: gorm.Model{ID: 620}, + Role: model.StudentType, + Courses: []model.Course{}, + } + StudentAllCourses = model.User{ + Model: gorm.Model{ID: 621}, + Role: model.StudentType, + Courses: []model.Course{HiddenCourse, EnrolledCourse, LoggedinCourse, PublicCourse}, + } + + // courses + AllCoursesForSearchTests = []model.Course{HiddenCourse, EnrolledCourse, LoggedinCourse, PublicCourse} + + HiddenCourse = model.Course{ + Model: gorm.Model{ID: HiddenCourseID}, + UserID: 1, + Name: "testen", + Slug: "coursehidden", + Year: 2024, + TeachingTerm: "W", + Visibility: "hidden", + Streams: []model.Stream{StreamHiddenCourse, PrivateStreamHiddenCourse}, + Admins: []model.User{}, + } + EnrolledCourse = model.Course{ + Model: gorm.Model{ID: EnrolledCourseID}, + UserID: 1, + Name: "testen", + Slug: "courseenrolled", + Year: 2024, + TeachingTerm: "W", + Visibility: "enrolled", + Streams: []model.Stream{StreamEnrolledCourse, PrivateStreamEnrolledCourse}, + Admins: []model.User{}, + } + LoggedinCourse = model.Course{ + Model: gorm.Model{ID: LoggedinCourseID}, + UserID: 1, + Name: "testen", + Slug: "courseloggedin", + Year: 2024, + TeachingTerm: "W", + Visibility: "loggedin", + Streams: []model.Stream{StreamLoggedinCourse, PrivateStreamLoggedinCourse}, + Admins: []model.User{}, + } + PublicCourse = model.Course{ + Model: gorm.Model{ID: PublicCourseID}, + UserID: 1, + Name: "testen", + Slug: "coursepublic", + Year: 2024, + TeachingTerm: "W", + Visibility: "public", + Streams: []model.Stream{StreamPublicCourse, PrivateStreamPublicCourse}, + Admins: []model.User{}, + } + + // streans + AllStreamsForSearchTests = []model.Stream{StreamHiddenCourse, PrivateStreamHiddenCourse, StreamEnrolledCourse, PrivateStreamEnrolledCourse, StreamLoggedinCourse, PrivateStreamLoggedinCourse, StreamPublicCourse, PrivateStreamPublicCourse} + + StreamHiddenCourse = model.Stream{ + Model: gorm.Model{ID: StreamIDHiddenCourse}, + Name: "testen", + Description: "testen", + CourseID: 400, + Recording: true, + Private: false, + } + PrivateStreamHiddenCourse = model.Stream{ + Model: gorm.Model{ID: PrivateStreamIDHiddenCourse}, + Name: "testen", + Description: "testen", + CourseID: 400, + Recording: true, + Private: true, + } + StreamEnrolledCourse = model.Stream{ + Model: gorm.Model{ID: StreamIDEnrolledCourse}, + Name: "testen", + Description: "testen", + CourseID: 401, + Recording: true, + Private: false, + } + PrivateStreamEnrolledCourse = model.Stream{ + Model: gorm.Model{ID: PrivateStreamIDEnrolledCourse}, + Name: "testen", + Description: "testen", + CourseID: 401, + Recording: true, + Private: true, + } + StreamLoggedinCourse = model.Stream{ + Model: gorm.Model{ID: StreamIDLoggedinCourse}, + Name: "testen", + Description: "testen", + CourseID: 402, + Recording: true, + Private: false, + } + PrivateStreamLoggedinCourse = model.Stream{ + Model: gorm.Model{ID: PrivateStreamIDLoggedinCourse}, + Name: "testen", + Description: "testen", + CourseID: 402, + Recording: true, + Private: true, + } + StreamPublicCourse = model.Stream{ + Model: gorm.Model{ID: StreamIDPublicCourse}, + Name: "testen", + Description: "testen", + CourseID: 403, + Recording: true, + Private: false, + } + PrivateStreamPublicCourse = model.Stream{ + Model: gorm.Model{ID: PrivateStreamIDPublicCourse}, + Name: "testen", + Description: "testen", + CourseID: 403, + Recording: true, + Private: true, + } + + // subtitles + AllSubtitlesForSearchTests = []tools.MeiliSubtitles{SubtitlesStreamHiddenCourse, SubtitlesPrivateStreamHiddenCourse, SubtitlesStreamEnrolledCourse, SubtitlesPrivateStreamEnrolledCourse, SubtitlesStreamLoggedinCourse, SubtitlesPrivateStreamLoggedinCourse, SubtitlesStreamPublicCourse, SubtitlesPrivateStreamPublicCourse} + SubtitlesStreamPublicCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDPublicCourseStream, + StreamID: StreamIDPublicCourse, + Text: "hallihallo1", + } + SubtitlesPrivateStreamPublicCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDPublicCoursePrivateStream, + StreamID: PrivateStreamIDPublicCourse, + Text: "hallihallo2", + } + SubtitlesStreamLoggedinCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDLoggedinCourseStream, + StreamID: StreamIDLoggedinCourse, + Text: "hallihallo1", + } + SubtitlesPrivateStreamLoggedinCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDLoggedinCoursePrivateStream, + StreamID: PrivateStreamIDLoggedinCourse, + Text: "hallihallo2", + } + SubtitlesStreamEnrolledCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDEnrolledCourseStream, + StreamID: StreamIDEnrolledCourse, + Text: "hallihallo1", + } + SubtitlesPrivateStreamEnrolledCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDEnrolledCoursePrivateStream, + StreamID: PrivateStreamIDEnrolledCourse, + Text: "hallihallo2", + } + SubtitlesStreamHiddenCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDHiddenCourseStream, + StreamID: StreamIDHiddenCourse, + Text: "hallihallo1", + } + SubtitlesPrivateStreamHiddenCourse = tools.MeiliSubtitles{ + ID: SubtitlesIDHiddenCoursePrivateStream, + StreamID: PrivateStreamIDHiddenCourse, + Text: "hallihallo2", + } +) + // CreateVideoSeekData returns list of generated VideoSeekChunk and expected response object func CreateVideoSeekData(streamId uint, chunkCount int) ([]model.VideoSeekChunk, gin.H) { var chunks []model.VideoSeekChunk diff --git a/web/admin.go b/web/admin.go index 8ee7389b9..0e5661fac 100644 --- a/web/admin.go +++ b/web/admin.go @@ -328,7 +328,7 @@ type AdminPageData struct { LectureHalls []model.LectureHall Page string Workers WorkersData - Semesters []dao.Semester + Semesters []model.Semester CurY int CurT string EditCourseData EditCourseData diff --git a/web/assets/css/home.css b/web/assets/css/home.css index 4a490579d..95c6b290a 100644 --- a/web/assets/css/home.css +++ b/web/assets/css/home.css @@ -421,4 +421,10 @@ label:has(~ .tum-live-input) { .tum-live-markdown a { border-bottom: 1px solid black; +} + +.tum-live-search-result-main { + @apply flex text-left rounded-lg text-4 text-sm py-1 px-2 mt-1 border border-black dark:border-gray-300 w-full text-black dark:text-gray-300; + width: 100%; + height: fit-content !important; } \ No newline at end of file diff --git a/web/index.go b/web/index.go index ebbc9fa80..394a9ac5f 100644 --- a/web/index.go +++ b/web/index.go @@ -88,7 +88,7 @@ type IndexData struct { Courses []model.Course PinnedCourses []model.Course PublicCourses []model.Course - Semesters []dao.Semester + Semesters []model.Semester CurrentYear int CurrentTerm string UserName string diff --git a/web/router.go b/web/router.go index f4a34c23a..3ac1e7a8a 100755 --- a/web/router.go +++ b/web/router.go @@ -111,6 +111,9 @@ func configMainRoute(router *gin.Engine) { router.GET("/imprint", routes.InfoPage(2, "imprint")) router.GET("/about", routes.InfoPage(3, "about")) + // search + router.GET("/search", routes.SearchPage) + // admins adminGroup := router.Group("/") adminGroup.GET("/admin/users", routes.AdminPage) @@ -204,6 +207,13 @@ func (r mainRoutes) home(c *gin.Context) { } } +func (r mainRoutes) SearchPage(c *gin.Context) { + indexData := NewIndexDataWithContext(c) + if err := templateExecutor.ExecuteTemplate(c.Writer, "search-page.gohtml", indexData); err != nil { + logger.Error("Could not execute template: 'search.gohtml'", "err", err) + } +} + func (r mainRoutes) semesterRedirect(c *gin.Context) { c.Redirect(http.StatusFound, fmt.Sprintf("/?year=%s&term=%s", c.Param("year"), c.Param("term"))) diff --git a/web/template/home.gohtml b/web/template/home.gohtml index f3c318b89..2ddccc456 100755 --- a/web/template/home.gohtml +++ b/web/template/home.gohtml @@ -54,14 +54,7 @@ - +{{template "search-global"}}
{{template "notifications"}} diff --git a/web/template/search-global.gohtml b/web/template/search-global.gohtml new file mode 100644 index 000000000..8958ea835 --- /dev/null +++ b/web/template/search-global.gohtml @@ -0,0 +1,153 @@ +{{define "search-global"}} + +{{end}} + +{{define "course-search-card"}} + +
  • + +

    +

    +
    +
  • +{{end}} + +{{define "stream-search-card"}} + +
  • + +

    +
    +
  • +{{end}} + +{{define "subtitle-search-card"}} + +
  • + +

    +

    +

    +

    +
    +
  • +{{end}} + +{{define "stream-search-results"}} +
    + + +
    +{{end}} + +{{define "course-search-results"}} +
    + + +
    +{{end}} + +{{define "subtitle-search-results"}} +
    + + +
    +{{end}} \ No newline at end of file diff --git a/web/template/search-page.gohtml b/web/template/search-page.gohtml new file mode 100644 index 000000000..d469b2f8d --- /dev/null +++ b/web/template/search-page.gohtml @@ -0,0 +1,375 @@ +{{- /*gotype: github.com/TUM-Dev/gocast/web.IndexData*/ -}} +{{$user := .TUMLiveContext.User}} +{{$userID := 0}} +{{if $user}}{{$userID = $user.ID}}{{end}} + + + + + + + + {{.Branding.Title}} + + {{if and .VersionTag (eq .VersionTag "development")}} + + {{else}} + + {{end}} + + + + + + + + + + + + + + +
    +
    + + +
    +
    + {{template "notifications"}} + {{if not $user}} + Login + {{else}} +
    + +
    + +
    +
    + {{end}} +
    +
    +
    +
    + +
    + +
    +{{template "footer" .VersionTag}} + + \ No newline at end of file diff --git a/web/ts/entry/home.ts b/web/ts/entry/home.ts index 8b0bbe8ab..ce9abaf4d 100644 --- a/web/ts/entry/home.ts +++ b/web/ts/entry/home.ts @@ -4,4 +4,6 @@ export * from "../components/livestreams"; export * from "../components/course"; export * from "../components/servernotifications"; export * from "../components/main"; +export * from "../search"; +export * from "../utilities/date"; export * from "../utilities/lectureHallValidator"; diff --git a/web/ts/search.ts b/web/ts/search.ts new file mode 100644 index 000000000..f0ec8d251 --- /dev/null +++ b/web/ts/search.ts @@ -0,0 +1,265 @@ +import { Semester } from "./api/semesters"; +import { Course, CoursesAPI } from "./api/courses"; +import { Alpine } from "alpinejs"; + +export function coursesSearch() { + return { + hits: [], + open: false, + searchInput: "", + search: function (year: number, teachingTerm: string) { + if (this.searchInput.length > 2) { + fetch(`/api/search/courses?q=${this.searchInput}&semester=${year}${teachingTerm}`).then((res) => { + if (res.ok) { + res.json().then((data) => { + this.hits = data.results[0].hits; + this.open = true; + }); + } + }); + } else { + this.hits = []; + this.open = false; + } + }, + }; +} + +export function isInCourse() { + const url = new URL(document.location.href); + const params = new URLSearchParams(url.search); + return params.has("slug") && params.has("year") && params.has("term"); +} + +export function searchPlaceholder() { + if (isInCourse()) { + return "Search in course"; + } + return "Search for course"; +} + +function getSemestersString(years: number[], teachingTerms: string[]): string { + let ret = ""; + if (years.length != teachingTerms.length) { + return ret; + } + for (let i = 0; i < years.length; i++) { + if (i == years.length - 1) { + ret += years[i] + teachingTerms[i]; + } else { + ret += years[i] + teachingTerms[i] + ","; + } + } + return ret; +} + +function getCoursesString(courses: Course[]): string { + let ret = ""; + for (let i = 0; i < courses.length; i++) { + if (i == courses.length - 1) { + ret += courses[i].Slug + courses[i].Year + courses[i].TeachingTerm; + } else { + ret += courses[i].Slug + courses[i].Year + courses[i].TeachingTerm + ","; + } + } + return ret; +} + +export function filteredSearch() { + return { + hits: {}, + open: false, + searchInput: "", + search: function (years: number[], teachingTerms: string[], courses: Course[], limit: number = 20) { + if (this.searchInput.length > 2) { + if ( + years.length < 8 && + teachingTerms.length < 8 && + teachingTerms.length == years.length && + courses.length < 3 + ) { + fetch( + `/api/search?q=${this.searchInput}&semester=${encodeURIComponent( + getSemestersString(years, teachingTerms), + )}&course=${encodeURIComponent(getCoursesString(courses))}&limit=${limit}`, + ).then((res) => { + if (res.ok) { + res.json().then((data) => { + this.hits = data; + this.open = true; + }); + } + }); + } + } else { + this.hits = {}; + this.open = false; + } + }, + searchWithDataFromPage: function ( + semesters: Semester[], + selectedSemesters: number[], + allCourses: Course[], + selectedCourses: number[], + ) { + const years = []; + const teachingTerms = []; + const courses = []; + + for (let i = 0; i < selectedSemesters.length; i++) { + years.push(semesters[selectedSemesters[i]].Year); + teachingTerms.push(semesters[selectedSemesters[i]].TeachingTerm); + } + for (let i = 0; i < selectedCourses.length; i++) { + courses.push(allCourses[selectedCourses[i]]); + } + this.search(years, teachingTerms, courses); + }, + }; +} + +export function globalSearch() { + return { + hits: {}, + open: false, + searchInput: "", + search: function (year: number = -1, teachingTerm: string = "", limit: number = 10) { + if (this.searchInput.length > 2) { + const url = new URL(document.location.href); + const params = new URLSearchParams(url.search); + if (params.has("slug") && params.has("year") && params.has("term")) { + fetch( + `/api/search?q=${this.searchInput}&course=${params.get("slug")}${params.get( + "year", + )}${params.get("term")}`, + ).then((res) => { + if (res.ok) { + res.json().then((data) => { + this.hits = data; + this.open = true; + }); + } + }); + } else if (year != -1 && teachingTerm != "") { + fetch(`/api/search?q=${this.searchInput}&limit=${limit}&semester=${year}${teachingTerm}`).then( + (res) => { + if (res.ok) { + res.json().then((data) => { + this.hits = data; + this.open = true; + }); + } + }, + ); + } else { + fetch(`/api/search?q=${this.searchInput}&limit=${limit}`).then((res) => { + if (res.ok) { + res.json().then((data) => { + this.hits = data; + this.open = true; + }); + } + }); + } + } else { + this.hits = {}; + this.open = false; + } + }, + }; +} + +export function initPopstateSearchBarListener() { + document.body.addEventListener("click", (event) => { + setTimeout(() => { + updateSearchBarPlaceholder(); + }, 50); + }); + console.log("Initialized popstate listener"); +} + +export function updateSearchBarPlaceholder() { + (document.getElementById("search-courses") as HTMLInputElement).placeholder = searchPlaceholder(); +} + +export function getSearchQueryFromParam() { + const url = new URL(document.location.href); + const params = new URLSearchParams(url.search); + return params.get("q"); +} + +export function getCourseFromParam() { + const url = new URL(document.location.href); + const params = new URLSearchParams(url.search); + return params.get("course"); +} + +export function getSemestersFromParam() { + const url = new URL(document.location.href); + const params = new URLSearchParams(url.search); + return params.get("semester"); +} + +export function generateCourseFromParam() { + const url = new URL(document.location.href); + const params = new URLSearchParams(url.search); + const slug = params.get("slug"); + const year = params.get("year"); + const term = params.get("term"); + return slug + year + term; +} + +export function getYearFromCourse(course: string) { + return parseInt(course.substring(course.length - 5, course.length - 1)); +} + +export function getTermFromCourse(course: string) { + return course.substring(course.length - 1, course.length); +} + +export function getSlugFromCourse(course: string) { + return course.substring(0, course.length - 5); +} + +export async function getCoursesOfSemesters(semesters: Semester[], filterSemesters: number[]): Promise { + let courses: Course[] = []; + for (let i = 0; i < filterSemesters.length; i++) { + courses = courses.concat( + await CoursesAPI.getPublic(semesters[filterSemesters[i]].Year, semesters[filterSemesters[i]].TeachingTerm), + ); + courses = courses.concat( + await CoursesAPI.getUsers(semesters[filterSemesters[i]].Year, semesters[filterSemesters[i]].TeachingTerm), + ); + } + courses = courses.filter((course, index, self) => self.findIndex((t) => t.Slug === course.Slug) === index); + return [...new Set(courses)]; +} + +export function initSearchBarArrowKeysListener() { + document.addEventListener("keydown", (event) => { + if (document.getElementById("search-results") == null) { + return; + } + const searchResults = document.getElementById("search-results").querySelectorAll("li[role='option']"); + const activeElement = document.activeElement as HTMLLIElement; + if (event.key == "ArrowDown") { + const currentIndex = Array.from(searchResults).indexOf(activeElement); + const nextIndex = currentIndex + 1; + if (nextIndex < searchResults.length) { + (searchResults[nextIndex] as HTMLLIElement).focus(); + } + } else if (event.key == "ArrowUp") { + const currentIndex = Array.from(searchResults).indexOf(activeElement); + const nextIndex = currentIndex - 1; + if (nextIndex >= 0) { + (searchResults[nextIndex] as HTMLLIElement).focus(); + } + } else if (event.key == "Enter") { + const currentIndex = Array.from(searchResults).indexOf(activeElement); + if (currentIndex >= 0 && currentIndex < searchResults.length) { + const curObj = searchResults[currentIndex]; + curObj.getElementsByTagName("a")[0].click(); + } + } + }); +} diff --git a/web/ts/utilities/date.ts b/web/ts/utilities/date.ts new file mode 100644 index 000000000..b4eb66235 --- /dev/null +++ b/web/ts/utilities/date.ts @@ -0,0 +1,4 @@ +export function datetimeToFriendly(time: string): string { + const date = new Date(time); + return date.toLocaleString(); +} diff --git a/web/ts/views/home.ts b/web/ts/views/home.ts index a5ff8c465..3174a233e 100644 --- a/web/ts/views/home.ts +++ b/web/ts/views/home.ts @@ -3,6 +3,7 @@ import { Semester, SemesterDTO, SemestersAPI } from "../api/semesters"; import { Course, CoursesAPI } from "../api/courses"; import { AlpineComponent } from "../components/alpine-component"; import { PinnedUpdate, Tunnel } from "../utilities/tunnels"; +import { updateSearchBarPlaceholder } from "../search"; export function skeleton(): AlpineComponent { return { @@ -77,6 +78,7 @@ export function skeleton(): AlpineComponent { view: View.Course, slug: this.state.slug, }); + updateSearchBarPlaceholder(); }, switchView(view: View) {