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