Refine report filtering and date formatting
This commit is contained in:
@@ -56,6 +56,12 @@ type Assignment struct {
|
|||||||
Category string
|
Category string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClassInfo struct {
|
||||||
|
URL string
|
||||||
|
Name string
|
||||||
|
Grade string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
@@ -88,16 +94,16 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
missing = filterMissingAssignments(missing, cfg.StartDate)
|
|
||||||
|
|
||||||
classLinks, err := fetchClassLinks(client, cfg.ScheduleURL)
|
classes, err := fetchScheduleClasses(client, cfg.ScheduleURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
missing = filterMissingAssignments(missing, cfg.StartDate, classesWithCurrentA(classes))
|
||||||
|
|
||||||
var upcoming []Assignment
|
var upcoming []Assignment
|
||||||
for _, classURL := range classLinks {
|
for _, classInfo := range classes {
|
||||||
items, err := fetchUpcomingAssignmentsForClass(client, classURL, now)
|
items, err := fetchUpcomingAssignmentsForClass(client, classInfo.URL, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -429,31 +435,57 @@ func fetchMissingAssignments(client *http.Client, assignmentsURL string, now tim
|
|||||||
return assignments, nil
|
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)
|
root, baseURL, err := fetchHTML(client, scheduleURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{}{}
|
seen := map[string]struct{}{}
|
||||||
var links []string
|
var classes []ClassInfo
|
||||||
for _, link := range findNodes(root, func(n *html.Node) bool {
|
for _, row := range findNodes(table, func(n *html.Node) bool {
|
||||||
return n.Type == html.ElementNode && n.Data == "a" &&
|
return n.Type == html.ElementNode && n.Data == "tr"
|
||||||
strings.Contains(attr(n, "href"), "/class/") &&
|
|
||||||
attr(n, "data-track") == "Class Home Page"
|
|
||||||
}) {
|
}) {
|
||||||
href := attr(link, "href")
|
cells := directChildCells(row)
|
||||||
if href == "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
classURL := resolveURL(baseURL, strings.SplitN(href, "?", 2)[0])
|
|
||||||
if _, ok := seen[classURL]; ok {
|
if _, ok := seen[classURL]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[classURL] = struct{}{}
|
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)
|
slices.SortFunc(classes, func(a, b ClassInfo) int {
|
||||||
return links, nil
|
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) {
|
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
|
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
|
var filtered []Assignment
|
||||||
for _, assignment := range assignments {
|
for _, assignment := range assignments {
|
||||||
if !assignment.DueDate.Before(startOfDay(startDate)) {
|
if assignment.DueDate.Before(startOfDay(startDate)) {
|
||||||
filtered = append(filtered, assignment)
|
continue
|
||||||
}
|
}
|
||||||
|
if hasCurrentA(coursesWithCurrentA, assignment.Course) && isRevisionStatus(assignment.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, assignment)
|
||||||
}
|
}
|
||||||
return filtered
|
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 {
|
func buildBody(missing, upcoming []Assignment, startDate time.Time, assignmentsURL, scheduleURL string, now time.Time, upcomingDays int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "Daily Alma missing assignments report for %s\n", now.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", startDate.Format("2006-01-02"))
|
fmt.Fprintf(&b, "Assignments due on or after %s\n\n", formatReportDate(startDate))
|
||||||
b.WriteString("Missing / Revision Needed\n")
|
b.WriteString("Missing / Revision Needed\n")
|
||||||
b.WriteString("-------------------------\n")
|
b.WriteString("-------------------------\n")
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
b.WriteString("No missing or revision-needed assignments matched the filter.\n")
|
b.WriteString("No missing or revision-needed assignments matched the filter.\n")
|
||||||
} else {
|
} else {
|
||||||
for _, assignment := range missing {
|
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, " Status: %s\n", assignment.Status)
|
||||||
fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
|
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")
|
b.WriteString("No upcoming assignments were due in the configured window.\n")
|
||||||
} else {
|
} else {
|
||||||
for _, assignment := range upcoming {
|
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, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming"))
|
||||||
fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
|
fmt.Fprintf(&b, " Link: %s\n", assignment.Link)
|
||||||
}
|
}
|
||||||
@@ -677,6 +713,30 @@ func normalizeSMTPHeloName(value string) string {
|
|||||||
return value + ".localdomain"
|
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 {
|
func inferAssignmentDate(monthDay string, now time.Time) time.Time {
|
||||||
return inferDate(monthDay, now, false)
|
return inferDate(monthDay, now, false)
|
||||||
}
|
}
|
||||||
@@ -778,6 +838,29 @@ func directChildrenByTag(node *html.Node, tag string) []*html.Node {
|
|||||||
return children
|
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 {
|
func hasClass(node *html.Node, class string) bool {
|
||||||
for _, item := range strings.Fields(attr(node, "class")) {
|
for _, item := range strings.Fields(attr(node, "class")) {
|
||||||
if item == class {
|
if item == class {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestDedupeAssignments(t *testing.T) {
|
||||||
assignment := Assignment{
|
assignment := Assignment{
|
||||||
DueDate: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
|
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(`
|
||||||
|
<table class="student-classes current-classes pure-table pure-table-horizontal">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a data-track="Class Home Page" href="/class/1?back=/schedule">Geometry Lab</a></td>
|
||||||
|
<td>S1, S2</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>Teacher</td>
|
||||||
|
<td>A</td>
|
||||||
|
<td>Aug 25, 2025</td>
|
||||||
|
<td>-</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a data-track="Class Home Page" href="/class/2?back=/schedule">Civics</a></td>
|
||||||
|
<td>S2</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>Teacher</td>
|
||||||
|
<td>B</td>
|
||||||
|
<td>Jan 20, 2026</td>
|
||||||
|
<td>-</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>`)
|
||||||
|
base, err := url.Parse("https://example.invalid/children/student/schedule")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse base URL: %v", err)
|
||||||
|
}
|
||||||
|
got, err := extractScheduleClasses(doc, base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractScheduleClasses returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d classes", len(got))
|
||||||
|
}
|
||||||
|
if got[1].Grade != "A" || got[1].Name != "Geometry Lab" {
|
||||||
|
t.Fatalf("unexpected classes: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBodyIncludesWeekday(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 30, 10, 0, 0, 0, time.UTC)
|
||||||
|
start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
|
||||||
|
body := buildBody([]Assignment{{
|
||||||
|
DueDate: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC),
|
||||||
|
Course: "Civics",
|
||||||
|
Title: "Essay",
|
||||||
|
Status: "Missing",
|
||||||
|
}}, nil, start, "https://example.invalid/assignments", "https://example.invalid/schedule", now, 14)
|
||||||
|
if !strings.Contains(body, "Monday 2026-03-30") {
|
||||||
|
t.Fatalf("body missing weekday date: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustParseHTML(text string) *html.Node {
|
func mustParseHTML(text string) *html.Node {
|
||||||
doc, err := html.Parse(strings.NewReader(text))
|
doc, err := html.Parse(strings.NewReader(text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user