diff --git a/flow/api/calendar/calendar.go b/flow/api/calendar/calendar.go index c1661eae2..d9c44b619 100644 --- a/flow/api/calendar/calendar.go +++ b/flow/api/calendar/calendar.go @@ -136,7 +136,7 @@ func writeCalendar(w io.Writer, secretId string, events []*webcalEvent) { const selectEventQuery = ` SELECT - sm.section_id, c.code, cs.section_name, sm.location, + sm.section_id, c.code, cs.section_name, COALESCE(NULLIF(us.location, ''), sm.location), sm.start_date :: TEXT, sm.end_date :: TEXT, sm.start_seconds, sm.end_seconds, sm.days FROM diff --git a/flow/api/calendar/calendar_test.go b/flow/api/calendar/calendar_test.go new file mode 100644 index 000000000..f3c39a4b1 --- /dev/null +++ b/flow/api/calendar/calendar_test.go @@ -0,0 +1,30 @@ +package calendar + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestWriteCalendar(t *testing.T) { + startTime := time.Date(2023, 10, 25, 14, 30, 0, 0, time.UTC) + events := []*webcalEvent{ + { + GroupId: 123, + Summary: "CS 135 - LEC 001", + StartTime: startTime, + EndTime: startTime.Add(1 * time.Hour), + Location: "MC 4045", + }, + } + + var output bytes.Buffer + writeCalendar(&output, "test_secret_id", events) + result := output.String() + + expectedTimestamp := "20231025T143000Z" + if !strings.Contains(result, "DTSTART:"+expectedTimestamp) { + t.Errorf("Expected output to contain start time %q, but got:\n%s", expectedTimestamp, result) + } +} diff --git a/flow/api/parse/parse.go b/flow/api/parse/parse.go index 4b9847c42..b0d594b10 100644 --- a/flow/api/parse/parse.go +++ b/flow/api/parse/parse.go @@ -132,8 +132,8 @@ WHERE user_id = $1 ` const insertScheduleQuery = ` -INSERT INTO user_schedule(user_id, section_id) -SELECT $1, id FROM course_section +INSERT INTO user_schedule(user_id, section_id, location) +SELECT $1, id, $4 FROM course_section WHERE class_number = $2 AND term_id = $3 ` @@ -147,7 +147,7 @@ func saveSchedule(tx *db.Tx, summary *schedule.Summary, userId int) (*scheduleRe } // Refuse to import empty schedule: we probably failed to parse it - if len(summary.ClassNumbers) == 0 { + if len(summary.Classes) == 0 { return nil, serde.WithStatus( http.StatusBadRequest, serde.WithEnum(serde.EmptySchedule, fmt.Errorf("empty schedule")), @@ -165,8 +165,8 @@ func saveSchedule(tx *db.Tx, summary *schedule.Summary, userId int) (*scheduleRe } var failedClasses []int - for _, classNumber := range summary.ClassNumbers { - tag, err := tx.Exec(insertScheduleQuery, userId, classNumber, summary.TermId) + for _, class := range summary.Classes { + tag, err := tx.Exec(insertScheduleQuery, userId, class.Number, summary.TermId, class.Location) if err != nil { return nil, fmt.Errorf("writing user_schedule: %w", err) } @@ -176,17 +176,17 @@ func saveSchedule(tx *db.Tx, summary *schedule.Summary, userId int) (*scheduleRe // Most likely UW API did not provide us with all of the available classes, // or we misparsed the class. if tag.RowsAffected() == 0 { - failedClasses = append(failedClasses, classNumber) - log.Printf("Schedule import failed for class number %d", classNumber) + failedClasses = append(failedClasses, class.Number) + log.Printf("Schedule import failed for class number %d", class.Number) } - _, err = tx.Exec(insertCourseTakenQuery, userId, summary.TermId, classNumber) + _, err = tx.Exec(insertCourseTakenQuery, userId, summary.TermId, class.Number) if err != nil { return nil, fmt.Errorf("writing user_course_taken: %w", err) } } - return &scheduleResponse{SectionsImported: len(summary.ClassNumbers), FailedClasses: failedClasses}, nil + return &scheduleResponse{SectionsImported: len(summary.Classes), FailedClasses: failedClasses}, nil } type scheduleRequest struct { diff --git a/flow/api/parse/schedule/schedule.go b/flow/api/parse/schedule/schedule.go index 97b3756bb..5cf2c71b8 100644 --- a/flow/api/parse/schedule/schedule.go +++ b/flow/api/parse/schedule/schedule.go @@ -4,28 +4,43 @@ import ( "fmt" "regexp" "strconv" + "strings" "flow/common/util" ) +type Class struct { + Number int + Location string +} + type Summary struct { // Term ids are numbers of the form 1189 (Fall 2018) TermId int - // Class numbers are four digits (e.g. 4895) - // and uniquely identify a section of a course within a term. - ClassNumbers []int + // Classes contains the parsed sections and their locations + Classes []Class } var ( termRegexp = regexp.MustCompile(`(Spring|Fall|Winter)\s+(\d{4})`) + // Class numbers are *the* four or five digit sequences // which occur on a separate line, perhaps parenthesized. // To be safe, we pre-emptively handle sequences up to length 8. // This should be fine since the only other numbers that appear // on their own line are the course code numbers (length 2 or 3). classNumberRegexp = regexp.MustCompile(`\n\(?(\d{4,8})\)?\n`) + + // Matches room locations that appear on their own line + // Building codes (alphanumeric with at least one letter) + space + room numbers, or TBA, or ONLN - Online + classroomRegexp = regexp.MustCompile(`(?m)^([A-Z0-9]*[A-Z][A-Z0-9]*\s+\d+|TBA|ONLN - Online)$`) ) +type match struct { + pos int + val string +} + func extractTerm(text string) (int, error) { submatches := termRegexp.FindStringSubmatchIndex(text) if submatches == nil { @@ -41,19 +56,28 @@ func extractTerm(text string) (int, error) { } } -func extractClassNumbers(text string) ([]int, error) { - var err error - // -1 corresponds to no limit on the number of matches +func extractClassNumbers(text string) ([]match, error) { submatches := classNumberRegexp.FindAllStringSubmatchIndex(text, -1) - classNumbers := make([]int, len(submatches)) + matches := make([]match, len(submatches)) for i, submatch := range submatches { - matchText := text[submatch[2]:submatch[3]] - classNumbers[i], err = strconv.Atoi(matchText) - if err != nil { - return nil, fmt.Errorf("%s is not a class number: %w", matchText, err) + matches[i] = match{ + pos: submatch[0], + val: text[submatch[2]:submatch[3]], } } - return classNumbers, nil + return matches, nil +} + +func extractClassrooms(text string) ([]match, error) { + submatches := classroomRegexp.FindAllStringSubmatchIndex(text, -1) + matches := make([]match, len(submatches)) + for i, submatch := range submatches { + matches[i] = match{ + pos: submatch[0], + val: text[submatch[2]:submatch[3]], + } + } + return matches, nil } func Parse(text string) (*Summary, error) { @@ -65,9 +89,61 @@ func Parse(text string) (*Summary, error) { if err != nil { return nil, fmt.Errorf("extracting class numbers: %w", err) } - summary := &Summary{ - TermId: term, - ClassNumbers: classNumbers, + classrooms, err := extractClassrooms(text) + if err != nil { + return nil, fmt.Errorf("extracting classrooms: %w", err) + } + + var classes []Class + roomIdx := 0 + + for i, cnMatch := range classNumbers { + cn, err := strconv.Atoi(cnMatch.val) + if err != nil { + return nil, fmt.Errorf("%s is not a class number: %w", cnMatch.val, err) + } + + // Determine the end position for this class's context. + // It ends where the NEXT class number begins. + // If this is the last class, the context goes to the end of the text. + nextPos := len(text) + if i+1 < len(classNumbers) { + nextPos = classNumbers[i+1].pos + } + + // Collect all classrooms that fall within (cnMatch.pos, nextPos) + var locs []string + for roomIdx < len(classrooms) { + room := classrooms[roomIdx] + if room.pos > nextPos { + // This room belongs to a future class + break + } + if room.pos > cnMatch.pos { + // Only add if it appears *after* the current class number start + locs = append(locs, room.val) + } + roomIdx++ + } + + // Dedup locations + seen := make(map[string]bool) + var uniqueLocs []string + for _, l := range locs { + if !seen[l] { + seen[l] = true + uniqueLocs = append(uniqueLocs, l) + } + } + + classes = append(classes, Class{ + Number: cn, + Location: strings.Join(uniqueLocs, ", "), + }) } - return summary, nil + + return &Summary{ + TermId: term, + Classes: classes, + }, nil } diff --git a/flow/api/parse/schedule/schedule_test.go b/flow/api/parse/schedule/schedule_test.go index cf311dfc6..d446d0493 100644 --- a/flow/api/parse/schedule/schedule_test.go +++ b/flow/api/parse/schedule/schedule_test.go @@ -18,8 +18,12 @@ func TestParseSchedule(t *testing.T) { "normal", &Summary{ TermId: 1199, - ClassNumbers: []int{ - 4896, 4897, 4899, 4741, 4742, 5003, 4747, 4748, 7993, 7994, 7995, 4751, 4752, + Classes: []Class{ + {4896, "MC 2038"}, {4897, "MC 4064, DWE 2527"}, {4899, "E3 2119"}, + {4741, "CPH 3681"}, {4742, "CPH 3681"}, {5003, "CPH 3681"}, + {4747, "CPH 3681"}, {4748, "CPH 3681"}, {7993, "MC 2034"}, + {7994, "CPH 3681"}, {7995, "CPH 1346"}, {4751, "CPH 3681"}, + {4752, "CPH 3681"}, }, }, }, @@ -28,8 +32,10 @@ func TestParseSchedule(t *testing.T) { "noparen", &Summary{ TermId: 1199, - ClassNumbers: []int{ - 5211, 8052, 9289, 6394, 5867, 6321, 6205, 7253, 7254, + Classes: []Class{ + {5211, "E7 2317"}, {8052, "RCH 101"}, {9289, "MC 2034"}, + {6394, "TBA"}, {5867, "MC 2017"}, {6321, "TBA"}, + {6205, "AL 124"}, {7253, "DC 1351"}, {7254, "DC 1351"}, }, }, }, @@ -38,8 +44,11 @@ func TestParseSchedule(t *testing.T) { "old", &Summary{ TermId: 1135, - ClassNumbers: []int{ - 3370, 3077, 3078, 3166, 2446, 4106, 4107, 4108, 4111, 4117, 4118, 4110, + Classes: []Class{ + {3370, "MC 4040"}, {3077, "QNC 1502"}, {3078, "QNC 1502"}, + {3166, "TBA"}, {2446, "STP 105"}, {4106, "RCH 307"}, + {4107, "MC 2038"}, {4108, "MC 2038"}, {4111, "TBA"}, + {4117, "MC 2038"}, {4118, "TBA"}, {4110, "TBA"}, }, }, }, @@ -48,8 +57,12 @@ func TestParseSchedule(t *testing.T) { "whitespace", &Summary{ TermId: 1199, - ClassNumbers: []int{ - 4669, 4658, 4660, 4699, 4655, 4656, 4661, 4662, 4850, 4664, 4666, 4936, 4639, 4668, 7634, + Classes: []Class{ + {4669, "E5 3102, E5 3101"}, {4658, "E5 3101"}, {4660, "DWE 3518"}, + {4699, "CPH 1346"}, {4655, "E5 3102, E5 3101"}, {4656, "MC 4063"}, + {4661, "E5 3101, E5 3102"}, {4662, "E5 3101"}, {4850, "E3 3164"}, + {4664, "E5 3101, E5 3102"}, {4666, "MC 4060"}, {4936, "E2 2363"}, + {4639, "E5 3101"}, {4668, "EV3 4412"}, {7634, "TBA"}, }, }, }, @@ -58,8 +71,10 @@ func TestParseSchedule(t *testing.T) { "long-classnumber", &Summary{ TermId: 1219, - ClassNumbers: []int{ - 4262, 11810, 9336, 6336, 6367, 10692, 10310, 8204, 10376, + Classes: []Class{ + {4262, "ONLN - Online"}, {11810, "ONLN - Online"}, {9336, "ONLN - Online"}, + {6336, "ONLN - Online"}, {6367, "ONLN - Online"}, {10692, "ONLN - Online"}, + {10310, "ONLN - Online"}, {8204, "ONLN - Online"}, {10376, "ONLN - Online"}, }, }, }, diff --git a/hasura/migrations/default/1767969620105_add_location_to_user_schedule/down.sql b/hasura/migrations/default/1767969620105_add_location_to_user_schedule/down.sql new file mode 100644 index 000000000..253572a38 --- /dev/null +++ b/hasura/migrations/default/1767969620105_add_location_to_user_schedule/down.sql @@ -0,0 +1 @@ +ALTER TABLE user_schedule DROP COLUMN location; \ No newline at end of file diff --git a/hasura/migrations/default/1767969620105_add_location_to_user_schedule/up.sql b/hasura/migrations/default/1767969620105_add_location_to_user_schedule/up.sql new file mode 100644 index 000000000..0c6cbec53 --- /dev/null +++ b/hasura/migrations/default/1767969620105_add_location_to_user_schedule/up.sql @@ -0,0 +1 @@ +ALTER TABLE user_schedule ADD COLUMN location TEXT; \ No newline at end of file diff --git a/script/test_calendar_export.sh b/script/test_calendar_export.sh new file mode 100755 index 000000000..19d523bab --- /dev/null +++ b/script/test_calendar_export.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e + +DB_CONTAINER="postgres" +DB_NAME="flow" +API_URL="http://localhost:8081" +SECRET_ID="0123456789abcdef" + +echo "=== 1. Setting up Test Data in DB '$DB_NAME' ===" + +# Adding the location column if missing +docker exec -i $DB_CONTAINER psql -U postgres -d $DB_NAME -c " +DO \$\$ +BEGIN + BEGIN + ALTER TABLE user_schedule ADD COLUMN location text; + EXCEPTION + WHEN duplicate_column THEN RAISE NOTICE 'column location already exists in user_schedule'; + END; +END \$\$;" + +# Insert test data +docker exec -i $DB_CONTAINER psql -U postgres -d $DB_NAME <