From 60161a383a95525494f61d6e80bf4487692548fb Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Mon, 5 Jan 2026 21:55:14 -0500 Subject: [PATCH 1/5] feat: update api service to parse and return classroom --- flow/api/parse/schedule/schedule.go | 22 ++++++++++++++++++++++ flow/api/parse/schedule/schedule_test.go | 15 +++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/flow/api/parse/schedule/schedule.go b/flow/api/parse/schedule/schedule.go index 97b3756bb..14e7f4bc5 100644 --- a/flow/api/parse/schedule/schedule.go +++ b/flow/api/parse/schedule/schedule.go @@ -14,16 +14,23 @@ type Summary struct { // Class numbers are four digits (e.g. 4895) // and uniquely identify a section of a course within a term. ClassNumbers []int + // Classrooms identify the location of the class (e.g. DWE 3422, ONLN - Online) + Classrooms []string } 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)$`) ) func extractTerm(text string) (int, error) { @@ -56,6 +63,16 @@ func extractClassNumbers(text string) ([]int, error) { return classNumbers, nil } +func extractClassrooms(text string) ([]string, error) { + submatches := classroomRegexp.FindAllStringSubmatchIndex(text, -1) + classrooms := make([]string, len(submatches)) + for i, submatch := range submatches { + matchText := text[submatch[2]:submatch[3]] + classrooms[i] = matchText + } + return classrooms, nil +} + func Parse(text string) (*Summary, error) { term, err := extractTerm(text) if err != nil { @@ -65,9 +82,14 @@ func Parse(text string) (*Summary, error) { if err != nil { return nil, fmt.Errorf("extracting class numbers: %w", err) } + classrooms, err := extractClassrooms(text) + if err != nil { + return nil, fmt.Errorf("extracting classrooms: %w", err) + } summary := &Summary{ TermId: term, ClassNumbers: classNumbers, + Classrooms: classrooms, } return summary, nil } diff --git a/flow/api/parse/schedule/schedule_test.go b/flow/api/parse/schedule/schedule_test.go index cf311dfc6..2f63d46c6 100644 --- a/flow/api/parse/schedule/schedule_test.go +++ b/flow/api/parse/schedule/schedule_test.go @@ -21,6 +21,9 @@ func TestParseSchedule(t *testing.T) { ClassNumbers: []int{ 4896, 4897, 4899, 4741, 4742, 5003, 4747, 4748, 7993, 7994, 7995, 4751, 4752, }, + Classrooms: []string{ + "MC 2038", "MC 4064", "DWE 2527", "E3 2119", "CPH 3681", "CPH 3681", "CPH 3681", "CPH 3681", "CPH 3681", "MC 2034", "CPH 3681", "CPH 1346", "CPH 3681", "CPH 3681", + }, }, }, // This schedule does not have parentheses around class numbers. @@ -31,6 +34,9 @@ func TestParseSchedule(t *testing.T) { ClassNumbers: []int{ 5211, 8052, 9289, 6394, 5867, 6321, 6205, 7253, 7254, }, + Classrooms: []string{ + "E7 2317", "RCH 101", "MC 2034", "TBA", "MC 2017", "TBA", "AL 124", "DC 1351", "DC 1351", + }, }, }, // This schedule is old (carried over from Flow 1.0) @@ -41,6 +47,9 @@ func TestParseSchedule(t *testing.T) { ClassNumbers: []int{ 3370, 3077, 3078, 3166, 2446, 4106, 4107, 4108, 4111, 4117, 4118, 4110, }, + Classrooms: []string{ + "MC 4040", "QNC 1502", "QNC 1502", "TBA", "STP 105", "RCH 307", "MC 2038", "MC 2038", "TBA", "TBA", "MC 2038", "TBA", "TBA", "TBA", + }, }, }, // This schedule has an abnormal amount of whitespace @@ -51,6 +60,9 @@ func TestParseSchedule(t *testing.T) { ClassNumbers: []int{ 4669, 4658, 4660, 4699, 4655, 4656, 4661, 4662, 4850, 4664, 4666, 4936, 4639, 4668, 7634, }, + Classrooms: []string{ + "E5 3102", "E5 3102", "E5 3101", "E5 3101", "E5 3101", "E5 3101", "DWE 3518", "CPH 1346", "E5 3102", "E5 3101", "E5 3101", "MC 4063", "E5 3101", "E5 3102", "E5 3101", "E5 3101", "E3 3164", "E5 3101", "E5 3102", "MC 4060", "E2 2363", "E2 2363", "E2 2363", "E2 2363", "E2 2363", "E5 3101", "E5 3101", "E5 3101", "EV3 4412", "TBA", + }, }, }, // This schedule has class codes longer than 4 digits @@ -61,6 +73,9 @@ func TestParseSchedule(t *testing.T) { ClassNumbers: []int{ 4262, 11810, 9336, 6336, 6367, 10692, 10310, 8204, 10376, }, + Classrooms: []string{ + "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", + }, }, }, } From 773379894d5586c575d052bc3a5e0b65db01e15b Mon Sep 17 00:00:00 2001 From: Alfred Date: Fri, 9 Jan 2026 19:30:58 -0500 Subject: [PATCH 2/5] Add location parsing to user schedule and calendar export --- flow/api/calendar/calendar.go | 2 +- flow/api/parse/parse.go | 18 ++-- flow/api/parse/schedule/schedule.go | 102 +++++++++++++----- flow/api/parse/schedule/schedule_test.go | 50 ++++----- .../down.sql | 1 + .../up.sql | 1 + 6 files changed, 115 insertions(+), 59 deletions(-) create mode 100644 hasura/migrations/default/1767969620105_add_location_to_user_schedule/down.sql create mode 100644 hasura/migrations/default/1767969620105_add_location_to_user_schedule/up.sql 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/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 14e7f4bc5..5cf2c71b8 100644 --- a/flow/api/parse/schedule/schedule.go +++ b/flow/api/parse/schedule/schedule.go @@ -4,18 +4,21 @@ 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 - // Classrooms identify the location of the class (e.g. DWE 3422, ONLN - Online) - Classrooms []string + // Classes contains the parsed sections and their locations + Classes []Class } var ( @@ -33,6 +36,11 @@ var ( 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 { @@ -48,29 +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) ([]string, error) { +func extractClassrooms(text string) ([]match, error) { submatches := classroomRegexp.FindAllStringSubmatchIndex(text, -1) - classrooms := make([]string, len(submatches)) + matches := make([]match, len(submatches)) for i, submatch := range submatches { - matchText := text[submatch[2]:submatch[3]] - classrooms[i] = matchText + matches[i] = match{ + pos: submatch[0], + val: text[submatch[2]:submatch[3]], + } } - return classrooms, nil + return matches, nil } func Parse(text string) (*Summary, error) { @@ -86,10 +93,57 @@ func Parse(text string) (*Summary, error) { if err != nil { return nil, fmt.Errorf("extracting classrooms: %w", err) } - summary := &Summary{ - TermId: term, - ClassNumbers: classNumbers, - Classrooms: classrooms, + + 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 2f63d46c6..d446d0493 100644 --- a/flow/api/parse/schedule/schedule_test.go +++ b/flow/api/parse/schedule/schedule_test.go @@ -18,11 +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, - }, - Classrooms: []string{ - "MC 2038", "MC 4064", "DWE 2527", "E3 2119", "CPH 3681", "CPH 3681", "CPH 3681", "CPH 3681", "CPH 3681", "MC 2034", "CPH 3681", "CPH 1346", "CPH 3681", "CPH 3681", + 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"}, }, }, }, @@ -31,11 +32,10 @@ func TestParseSchedule(t *testing.T) { "noparen", &Summary{ TermId: 1199, - ClassNumbers: []int{ - 5211, 8052, 9289, 6394, 5867, 6321, 6205, 7253, 7254, - }, - Classrooms: []string{ - "E7 2317", "RCH 101", "MC 2034", "TBA", "MC 2017", "TBA", "AL 124", "DC 1351", "DC 1351", + 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"}, }, }, }, @@ -44,11 +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, - }, - Classrooms: []string{ - "MC 4040", "QNC 1502", "QNC 1502", "TBA", "STP 105", "RCH 307", "MC 2038", "MC 2038", "TBA", "TBA", "MC 2038", "TBA", "TBA", "TBA", + 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"}, }, }, }, @@ -57,11 +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, - }, - Classrooms: []string{ - "E5 3102", "E5 3102", "E5 3101", "E5 3101", "E5 3101", "E5 3101", "DWE 3518", "CPH 1346", "E5 3102", "E5 3101", "E5 3101", "MC 4063", "E5 3101", "E5 3102", "E5 3101", "E5 3101", "E3 3164", "E5 3101", "E5 3102", "MC 4060", "E2 2363", "E2 2363", "E2 2363", "E2 2363", "E2 2363", "E5 3101", "E5 3101", "E5 3101", "EV3 4412", "TBA", + 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"}, }, }, }, @@ -70,11 +71,10 @@ func TestParseSchedule(t *testing.T) { "long-classnumber", &Summary{ TermId: 1219, - ClassNumbers: []int{ - 4262, 11810, 9336, 6336, 6367, 10692, 10310, 8204, 10376, - }, - Classrooms: []string{ - "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", "ONLN - Online", + 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 From 44b45a04fd693383a98c003afd52bde30411e798 Mon Sep 17 00:00:00 2001 From: Alfred Date: Tue, 20 Jan 2026 21:33:29 -0500 Subject: [PATCH 3/5] feat:add tests for calendar exports --- flow/api/calendar/calendar_test.go | 30 ++++++++++++++++++ script/test_calendar_export.sh | 51 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 flow/api/calendar/calendar_test.go create mode 100755 script/test_calendar_export.sh 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/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 < Date: Thu, 19 Feb 2026 11:55:39 -0500 Subject: [PATCH 4/5] remove test_calendar_export script --- script/test_calendar_export.sh | 51 ---------------------------------- 1 file changed, 51 deletions(-) delete mode 100755 script/test_calendar_export.sh diff --git a/script/test_calendar_export.sh b/script/test_calendar_export.sh deleted file mode 100755 index 19d523bab..000000000 --- a/script/test_calendar_export.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/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 < Date: Mon, 23 Feb 2026 14:29:08 -0500 Subject: [PATCH 5/5] feat: normalize whitespace for dedup --- flow/api/parse/schedule/schedule.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flow/api/parse/schedule/schedule.go b/flow/api/parse/schedule/schedule.go index 5cf2c71b8..8e406667d 100644 --- a/flow/api/parse/schedule/schedule.go +++ b/flow/api/parse/schedule/schedule.go @@ -130,9 +130,11 @@ func Parse(text string) (*Summary, error) { seen := make(map[string]bool) var uniqueLocs []string for _, l := range locs { - if !seen[l] { - seen[l] = true - uniqueLocs = append(uniqueLocs, l) + normalizedLoc := strings.TrimSpace(l) + + if !seen[normalizedLoc] { + seen[normalizedLoc] = true + uniqueLocs = append(uniqueLocs, normalizedLoc) } }