Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion flow/api/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions flow/api/calendar/calendar_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 9 additions & 9 deletions flow/api/parse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
`

Expand All @@ -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")),
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
108 changes: 92 additions & 16 deletions flow/api/parse/schedule/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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
}
35 changes: 25 additions & 10 deletions flow/api/parse/schedule/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
},
},
Expand All @@ -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"},
},
},
},
Expand All @@ -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"},
},
},
},
Expand All @@ -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"},
},
},
},
Expand All @@ -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"},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE user_schedule DROP COLUMN location;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE user_schedule ADD COLUMN location TEXT;
51 changes: 51 additions & 0 deletions script/test_calendar_export.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
-- Create Test User
INSERT INTO "user" (secret_id, first_name, last_name, join_source)
VALUES ('$SECRET_ID', 'Test', 'User', 'email')
ON CONFLICT (secret_id) DO NOTHING;

DELETE FROM section_meeting WHERE section_id = 1;

INSERT INTO section_meeting (
section_id, location, start_date, end_date, start_seconds, end_seconds, days,
is_cancelled, is_closed, is_tba
)
VALUES (
1, 'RCH 301', '2025-09-01', '2025-12-20', 36000, 39600, '{"M", "W", "F"}',
false, false, false
);


INSERT INTO user_schedule (user_id, section_id)
SELECT id, 1 FROM "user" WHERE secret_id = '$SECRET_ID'
ON CONFLICT DO NOTHING;
EOF

echo "=== 2. Downloading Calendar ==="
curl -f "$API_URL/calendar/$SECRET_ID.ics" -o test_output.ics

echo ""
echo "=== DONE ==="
echo "Check the test_output.ics file generated."