diff --git a/main.go b/main.go index dbdc367..bad2b13 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,12 @@ type Assignment struct { Category string } +type ClassInfo struct { + URL string + Name string + Grade string +} + func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) @@ -88,16 +94,16 @@ func run() error { if err != nil { return err } - missing = filterMissingAssignments(missing, cfg.StartDate) - classLinks, err := fetchClassLinks(client, cfg.ScheduleURL) + classes, err := fetchScheduleClasses(client, cfg.ScheduleURL) if err != nil { return err } + missing = filterMissingAssignments(missing, cfg.StartDate, classesWithCurrentA(classes)) var upcoming []Assignment - for _, classURL := range classLinks { - items, err := fetchUpcomingAssignmentsForClass(client, classURL, now) + for _, classInfo := range classes { + items, err := fetchUpcomingAssignmentsForClass(client, classInfo.URL, now) if err != nil { return err } @@ -429,31 +435,57 @@ func fetchMissingAssignments(client *http.Client, assignmentsURL string, now tim return assignments, nil } -func fetchClassLinks(client *http.Client, scheduleURL string) ([]string, error) { +func fetchScheduleClasses(client *http.Client, scheduleURL string) ([]ClassInfo, error) { root, baseURL, err := fetchHTML(client, scheduleURL) if err != nil { return nil, err } + return extractScheduleClasses(root, baseURL) +} + +func extractScheduleClasses(root *html.Node, baseURL *url.URL) ([]ClassInfo, error) { + table := findNode(root, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "current-classes") + }) + if table == nil { + return nil, errors.New("current classes table not found") + } + seen := map[string]struct{}{} - var links []string - for _, link := range findNodes(root, func(n *html.Node) bool { - return n.Type == html.ElementNode && n.Data == "a" && - strings.Contains(attr(n, "href"), "/class/") && - attr(n, "data-track") == "Class Home Page" + var classes []ClassInfo + for _, row := range findNodes(table, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "tr" }) { - href := attr(link, "href") - if href == "" { + cells := directChildCells(row) + if len(cells) < 5 { + continue + } + link := findNode(cells[0], func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "a" && + strings.Contains(attr(n, "href"), "/class/") && + attr(n, "data-track") == "Class Home Page" + }) + if link == nil { + continue + } + classURL := resolveURL(baseURL, strings.SplitN(attr(link, "href"), "?", 2)[0]) + if classURL == "" { continue } - classURL := resolveURL(baseURL, strings.SplitN(href, "?", 2)[0]) if _, ok := seen[classURL]; ok { continue } seen[classURL] = struct{}{} - links = append(links, classURL) + classes = append(classes, ClassInfo{ + URL: classURL, + Name: firstNonEmpty(ownText(link), nodeText(link)), + Grade: nodeText(cells[4]), + }) } - slices.Sort(links) - return links, nil + slices.SortFunc(classes, func(a, b ClassInfo) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + return classes, nil } func fetchUpcomingAssignmentsForClass(client *http.Client, classURL string, now time.Time) ([]Assignment, error) { @@ -519,12 +551,16 @@ func fetchHTML(client *http.Client, rawURL string) (*html.Node, *url.URL, error) return doc, resp.Request.URL, nil } -func filterMissingAssignments(assignments []Assignment, startDate time.Time) []Assignment { +func filterMissingAssignments(assignments []Assignment, startDate time.Time, coursesWithCurrentA map[string]struct{}) []Assignment { var filtered []Assignment for _, assignment := range assignments { - if !assignment.DueDate.Before(startOfDay(startDate)) { - filtered = append(filtered, assignment) + if assignment.DueDate.Before(startOfDay(startDate)) { + continue } + if hasCurrentA(coursesWithCurrentA, assignment.Course) && isRevisionStatus(assignment.Status) { + continue + } + filtered = append(filtered, assignment) } return filtered } @@ -568,15 +604,15 @@ func buildSubject(missing, upcoming []Assignment, startDate time.Time, upcomingD func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsURL, scheduleURL string, now time.Time, upcomingDays int) string { var b strings.Builder - fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", now.Format("2006-01-02")) - fmt.Fprintf(&b, "Assignments due on or after %s\n\n", startDate.Format("2006-01-02")) + fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", formatReportDate(now)) + fmt.Fprintf(&b, "Assignments due on or after %s\n\n", formatReportDate(startDate)) b.WriteString("Missing / Revision Needed\n") b.WriteString("-------------------------\n") if len(missing) == 0 { b.WriteString("No missing or revision-needed assignments matched the filter.\n") } else { for _, assignment := range missing { - fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title) + fmt.Fprintf(&b, "- %s | %s | %s\n", formatReportDate(assignment.DueDate), assignment.Course, assignment.Title) fmt.Fprintf(&b, " Status: %s\n", assignment.Status) fmt.Fprintf(&b, " Link: %s\n", assignment.Link) } @@ -587,7 +623,7 @@ func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsU b.WriteString("No upcoming assignments were due in the configured window.\n") } else { for _, assignment := range upcoming { - fmt.Fprintf(&b, "- %s | %s | %s\n", assignment.DueDate.Format("2006-01-02"), assignment.Course, assignment.Title) + fmt.Fprintf(&b, "- %s | %s | %s\n", formatReportDate(assignment.DueDate), assignment.Course, assignment.Title) fmt.Fprintf(&b, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming")) fmt.Fprintf(&b, " Link: %s\n", assignment.Link) } @@ -677,6 +713,30 @@ func normalizeSMTPHeloName(value string) string { return value + ".localdomain" } +func formatReportDate(t time.Time) string { + return t.Format("Monday 2006-01-02") +} + +func classesWithCurrentA(classes []ClassInfo) map[string]struct{} { + courses := map[string]struct{}{} + for _, classInfo := range classes { + if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(classInfo.Grade)), "A") { + courses[strings.ToLower(strings.TrimSpace(classInfo.Name))] = struct{}{} + } + } + return courses +} + +func hasCurrentA(courses map[string]struct{}, course string) bool { + _, ok := courses[strings.ToLower(strings.TrimSpace(course))] + return ok +} + +func isRevisionStatus(status string) bool { + status = strings.ToLower(strings.TrimSpace(status)) + return strings.Contains(status, "revision") || strings.Contains(status, "revise") +} + func inferAssignmentDate(monthDay string, now time.Time) time.Time { return inferDate(monthDay, now, false) } @@ -778,6 +838,29 @@ func directChildrenByTag(node *html.Node, tag string) []*html.Node { return children } +func directChildCells(node *html.Node) []*html.Node { + var children []*html.Node + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && (child.Data == "th" || child.Data == "td") { + children = append(children, child) + } + } + return children +} + +func ownText(node *html.Node) string { + var parts []string + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.TextNode { + text := strings.TrimSpace(child.Data) + if text != "" { + parts = append(parts, text) + } + } + } + return strings.Join(parts, " ") +} + func hasClass(node *html.Node, class string) bool { for _, item := range strings.Fields(attr(node, "class")) { if item == class { diff --git a/main_test.go b/main_test.go index 50088c2..5e749f1 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "net/url" "os" "strings" "testing" @@ -48,6 +49,28 @@ func TestFilterUpcomingAssignments(t *testing.T) { } } +func TestFilterMissingAssignmentsHidesRevisionForCurrentA(t *testing.T) { + start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC) + assignments := []Assignment{ + { + DueDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + Course: "Geometry Lab", + Title: "Revise me", + Status: "Revision Needed", + }, + { + DueDate: time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), + Course: "Geometry Lab", + Title: "Still missing", + Status: "Missing", + }, + } + got := filterMissingAssignments(assignments, start, map[string]struct{}{"geometry lab": {}}) + if len(got) != 1 || got[0].Title != "Still missing" { + t.Fatalf("unexpected filtered assignments: %#v", got) + } +} + func TestDedupeAssignments(t *testing.T) { assignment := Assignment{ DueDate: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), @@ -141,6 +164,77 @@ func TestNormalizeSMTPHeloName(t *testing.T) { } } +func TestClassesWithCurrentA(t *testing.T) { + got := classesWithCurrentA([]ClassInfo{ + {Name: "Geometry Lab", Grade: "A"}, + {Name: "Civics", Grade: "B"}, + {Name: "Intro. Spanish 1", Grade: "A-"}, + }) + if !hasCurrentA(got, "Geometry Lab") { + t.Fatal("expected Geometry Lab to have current A") + } + if !hasCurrentA(got, "Intro. Spanish 1") { + t.Fatal("expected Intro. Spanish 1 to have current A") + } + if hasCurrentA(got, "Civics") { + t.Fatal("did not expect Civics to have current A") + } +} + +func TestFetchScheduleClasses(t *testing.T) { + doc := mustParseHTML(` +
| Geometry Lab | +S1, S2 | +- | +Teacher | +A | +Aug 25, 2025 | +- | +
| Civics | +S2 | +- | +Teacher | +B | +Jan 20, 2026 | +- | +