From 5d1be582b52eebc84c5a1c60df90486a69c62c64 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sat, 28 Mar 2026 11:06:40 -0700 Subject: [PATCH] Initial Alma assignments reporter --- .gitignore | 1 + Dockerfile | 17 + README.md | 55 +++ cronjob.example.yaml | 54 +++ go.mod | 5 + go.sum | 2 + main.go | 799 +++++++++++++++++++++++++++++++++++++++++++ main_test.go | 102 ++++++ 8 files changed, 1035 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cronjob.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27ce249 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +alma-assignments-reporter diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc9b781 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.26 AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/alma-assignments-reporter . + +FROM scratch + +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /out/alma-assignments-reporter /alma-assignments-reporter + +ENTRYPOINT ["/alma-assignments-reporter"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..58d282d --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Alma Assignments Reporter + +This service logs into Alma, reads the `Missing / Revise` assignments table, walks the schedule page to find each class, collects upcoming assignments from each class page, and emails a daily report. + +## Configuration + +Required when `SEND_EMAIL=true`: + +- `SMTP_HOST` +- `EMAIL_FROM` +- `EMAIL_TO` + +Recommended settings: + +- `ALMA_CREDS_FILE=/config/alma.creds` +- `ALMA_ASSIGNMENTS_URL=https://example.invalid/children/student-id/assignments` +- `ALMA_SCHEDULE_URL=https://example.invalid/children/student-id/schedule` +- `ALMA_START_DATE=2026-01-20` +- `ALMA_UPCOMING_DAYS=14` +- `SMTP_PORT=587` +- `SMTP_STARTTLS=true` +- `SMTP_USERNAME` +- `SMTP_PASSWORD` +- `PRINT_REPORT=true` +- `SEND_EMAIL=false` + +Any setting can also be supplied via a `*_FILE` variant such as `SMTP_PASSWORD_FILE`. + +The Alma credentials file format is: + +```yaml +username: your-alma-username +password: your-alma-password +``` + +## Local run + +```bash +go test ./... +PRINT_REPORT=true SEND_EMAIL=false ALMA_CREDS_FILE=/path/to/alma.creds go run . +``` + +## Container + +Build: + +```bash +docker build -t example.invalid/alma-assignments-reporter:latest . +``` + +The container image uses a static Go binary in `scratch`, with only the CA bundle copied in for HTTPS and SMTP TLS. + +## Kubernetes + +Use a Secret for Alma and SMTP credentials. The example manifest in `cronjob.example.yaml` mounts Alma credentials at `/config/alma.creds` and reads SMTP credentials from secret-backed environment variables. diff --git a/cronjob.example.yaml b/cronjob.example.yaml new file mode 100644 index 0000000..49efc6a --- /dev/null +++ b/cronjob.example.yaml @@ -0,0 +1,54 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: alma-assignments-reporter + namespace: default +spec: + schedule: "0 14 * * *" + jobTemplate: + spec: + template: + spec: + restartPolicy: Never + containers: + - name: reporter + image: example.invalid/alma-assignments-reporter:latest + env: + - name: ALMA_ASSIGNMENTS_URL + value: https://example.invalid/children/student-id/assignments + - name: ALMA_SCHEDULE_URL + value: https://example.invalid/children/student-id/schedule + - name: ALMA_START_DATE + value: "2026-01-20" + - name: ALMA_UPCOMING_DAYS + value: "14" + - name: ALMA_CREDS_FILE + value: /config/alma.creds + - name: SMTP_HOST + value: smtp.email.svc.cluster.local + - name: SMTP_PORT + value: "587" + - name: SMTP_STARTTLS + value: "false" + - name: EMAIL_FROM + value: alma-reporter@example.invalid + - name: EMAIL_TO + value: parent@example.invalid + - name: SMTP_USERNAME + valueFrom: + secretKeyRef: + name: alma-assignments-reporter-smtp + key: username + - name: SMTP_PASSWORD + valueFrom: + secretKeyRef: + name: alma-assignments-reporter-smtp + key: password + volumeMounts: + - name: alma-creds + mountPath: /config + readOnly: true + volumes: + - name: alma-creds + secret: + secretName: alma-assignments-reporter-alma diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4432abd --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.julianfamily.org/joejulian/alma-assignments-reporter + +go 1.26.0 + +require golang.org/x/net v0.43.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8028634 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3095a60 --- /dev/null +++ b/main.go @@ -0,0 +1,799 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/smtp" + "net/textproto" + "net/url" + "os" + "path" + "slices" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" +) + +const ( + defaultStartDate = "2026-01-20" + defaultUpcomingDays = 14 + userAgent = "alma-assignments-reporter/1.0" +) + +type Config struct { + AssignmentsURL string + ScheduleURL string + StartDate time.Time + UpcomingDays int + AlmaCredsFile string + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPStartTLS bool + EmailFrom string + EmailTo []string + PrintReport bool + SendEmail bool +} + +type Assignment struct { + DueDate time.Time + Course string + Title string + Status string + Link string + Category string +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := loadConfig() + if err != nil { + return err + } + + creds, err := loadAlmaCreds(cfg.AlmaCredsFile) + if err != nil { + return err + } + + client, err := newHTTPClient() + if err != nil { + return err + } + + if err := login(client, cfg.AssignmentsURL, creds["username"], creds["password"]); err != nil { + return err + } + + now := time.Now() + missing, err := fetchMissingAssignments(client, cfg.AssignmentsURL, now) + if err != nil { + return err + } + missing = filterMissingAssignments(missing, cfg.StartDate) + + classLinks, err := fetchClassLinks(client, cfg.ScheduleURL) + if err != nil { + return err + } + + var upcoming []Assignment + for _, classURL := range classLinks { + items, err := fetchUpcomingAssignmentsForClass(client, classURL, now) + if err != nil { + return err + } + upcoming = append(upcoming, items...) + } + upcoming = dedupeAssignments(upcoming) + upcoming = filterUpcomingAssignments(upcoming, now, cfg.UpcomingDays) + + subject := buildSubject(missing, upcoming, cfg.StartDate, cfg.UpcomingDays) + body := buildBody(missing, upcoming, cfg.StartDate, cfg.AssignmentsURL, cfg.ScheduleURL, now, cfg.UpcomingDays) + + if cfg.PrintReport { + fmt.Println(subject) + fmt.Println() + fmt.Print(body) + } + + if cfg.SendEmail { + if err := sendEmail(cfg, subject, body); err != nil { + return err + } + } + + return nil +} + +func loadConfig() (Config, error) { + assignmentsURL, err := readValue("ALMA_ASSIGNMENTS_URL", "", true) + if err != nil { + return Config{}, err + } + scheduleURL, err := readValue("ALMA_SCHEDULE_URL", deriveScheduleURL(assignmentsURL), true) + if err != nil { + return Config{}, err + } + startDateText, err := readValue("ALMA_START_DATE", defaultStartDate, true) + if err != nil { + return Config{}, err + } + startDate, err := time.Parse("2006-01-02", startDateText) + if err != nil { + return Config{}, fmt.Errorf("parse ALMA_START_DATE: %w", err) + } + upcomingDaysText, err := readValue("ALMA_UPCOMING_DAYS", strconv.Itoa(defaultUpcomingDays), true) + if err != nil { + return Config{}, err + } + upcomingDays, err := strconv.Atoi(upcomingDaysText) + if err != nil { + return Config{}, fmt.Errorf("parse ALMA_UPCOMING_DAYS: %w", err) + } + almaCredsFile, err := readValue("ALMA_CREDS_FILE", "/config/alma.creds", true) + if err != nil { + return Config{}, err + } + smtpHost, err := readValue("SMTP_HOST", "", false) + if err != nil { + return Config{}, err + } + smtpPortText, err := readValue("SMTP_PORT", "587", false) + if err != nil { + return Config{}, err + } + smtpPort, err := strconv.Atoi(smtpPortText) + if err != nil { + return Config{}, fmt.Errorf("parse SMTP_PORT: %w", err) + } + smtpUsername, err := readValue("SMTP_USERNAME", "", false) + if err != nil { + return Config{}, err + } + smtpPassword, err := readValue("SMTP_PASSWORD", "", false) + if err != nil { + return Config{}, err + } + smtpStartTLS := readBool("SMTP_STARTTLS", true) + emailFrom, err := readValue("EMAIL_FROM", "", false) + if err != nil { + return Config{}, err + } + emailToText, err := readValue("EMAIL_TO", "", false) + if err != nil { + return Config{}, err + } + emailTo := splitCSV(emailToText) + printReport := readBool("PRINT_REPORT", false) + sendEmailFlag := readBool("SEND_EMAIL", true) + + if sendEmailFlag { + if smtpHost == "" || emailFrom == "" || len(emailTo) == 0 { + return Config{}, errors.New("SMTP_HOST, EMAIL_FROM, and EMAIL_TO are required when SEND_EMAIL is true") + } + if smtpUsername != "" && smtpPassword == "" { + return Config{}, errors.New("SMTP_PASSWORD is required when SMTP_USERNAME is set") + } + } + + return Config{ + AssignmentsURL: assignmentsURL, + ScheduleURL: scheduleURL, + StartDate: startDate, + UpcomingDays: upcomingDays, + AlmaCredsFile: almaCredsFile, + SMTPHost: smtpHost, + SMTPPort: smtpPort, + SMTPUsername: smtpUsername, + SMTPPassword: smtpPassword, + SMTPStartTLS: smtpStartTLS, + EmailFrom: emailFrom, + EmailTo: emailTo, + PrintReport: printReport, + SendEmail: sendEmailFlag, + }, nil +} + +func deriveScheduleURL(assignmentsURL string) string { + parsed, err := url.Parse(assignmentsURL) + if err != nil { + return assignmentsURL + } + parsed.Path = path.Dir(parsed.Path) + "/schedule" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() +} + +func readValue(name, fallback string, required bool) (string, error) { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value, nil + } + if filePath := strings.TrimSpace(os.Getenv(name + "_FILE")); filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("read %s_FILE: %w", name, err) + } + return strings.TrimSpace(string(data)), nil + } + if fallback != "" { + return fallback, nil + } + if required { + return "", fmt.Errorf("missing required setting: %s or %s_FILE", name, name) + } + return "", nil +} + +func readBool(name string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(name))) + if value == "" { + return fallback + } + switch value { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func splitCSV(value string) []string { + var items []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + items = append(items, part) + } + } + return items +} + +func loadAlmaCreds(filePath string) (map[string]string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read alma creds: %w", err) + } + + values := map[string]string{} + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + if envUser, _ := readValue("ALMA_USERNAME", "", false); envUser != "" { + values["username"] = envUser + } + if envPass, _ := readValue("ALMA_PASSWORD", "", false); envPass != "" { + values["password"] = envPass + } + + if values["username"] == "" || values["password"] == "" { + return nil, errors.New("alma creds file must contain username and password") + } + return values, nil +} + +func newHTTPClient() (*http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + return &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + }, nil +} + +func login(client *http.Client, assignmentsURL, username, password string) error { + initialReq, err := http.NewRequest(http.MethodGet, assignmentsURL, nil) + if err != nil { + return err + } + initialReq.Header.Set("User-Agent", userAgent) + initialResp, err := client.Do(initialReq) + if err != nil { + return err + } + defer initialResp.Body.Close() + if initialResp.StatusCode >= 400 { + return fmt.Errorf("alma initial request failed: %s", initialResp.Status) + } + + parsedURL, err := url.Parse(assignmentsURL) + if err != nil { + return err + } + form := url.Values{ + "username": []string{username}, + "password": []string{password}, + "_done": []string{parsedURL.Path}, + "deviceKey": []string{""}, + } + loginURL := parsedURL.ResolveReference(&url.URL{Path: "/login"}) + req, err := http.NewRequest(http.MethodPost, loginURL.String(), strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("alma login failed: %s", resp.Status) + } + if strings.Contains(resp.Request.URL.Path, "/login") || !bytes.Contains(bytes.ToLower(body), []byte("logout")) { + return errors.New("alma login failed") + } + return nil +} + +func fetchMissingAssignments(client *http.Client, assignmentsURL string, now time.Time) ([]Assignment, error) { + root, baseURL, err := fetchHTML(client, assignmentsURL) + if err != nil { + return nil, err + } + table := findNode(root, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "missing-assignments") + }) + if table == nil { + return nil, errors.New("missing assignments table not found") + } + + var assignments []Assignment + for _, row := range findNodes(table, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "tr" + }) { + cells := directChildrenByTag(row, "td") + if len(cells) < 3 { + continue + } + courseLink := findNode(cells[1], func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "a" && parentIsTag(n, "small") + }) + assignmentLink := findNode(cells[1], func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != "" + }) + if courseLink == nil || assignmentLink == nil { + continue + } + assignments = append(assignments, Assignment{ + DueDate: inferAssignmentDate(nodeText(cells[0]), now), + Course: nodeText(courseLink), + Title: nodeText(assignmentLink), + Status: firstNonEmpty(attr(findNode(cells[2], func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "i" && attr(n, "title") != "" + }), "title"), nodeText(cells[2]), "Unknown"), + Link: resolveURL(baseURL, attr(assignmentLink, "href")), + }) + } + + sortAssignments(assignments) + return assignments, nil +} + +func fetchClassLinks(client *http.Client, scheduleURL string) ([]string, error) { + root, baseURL, err := fetchHTML(client, scheduleURL) + if err != nil { + return nil, err + } + 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" + }) { + href := attr(link, "href") + if href == "" { + continue + } + classURL := resolveURL(baseURL, strings.SplitN(href, "?", 2)[0]) + if _, ok := seen[classURL]; ok { + continue + } + seen[classURL] = struct{}{} + links = append(links, classURL) + } + slices.Sort(links) + return links, nil +} + +func fetchUpcomingAssignmentsForClass(client *http.Client, classURL string, now time.Time) ([]Assignment, error) { + root, baseURL, err := fetchHTML(client, classURL) + if err != nil { + return nil, err + } + table := findNode(root, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "upcoming-assignments") + }) + if table == nil { + return nil, nil + } + course := nodeText(findNode(root, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "h3" && ancestorHasClass(n, "class-header") + })) + + var assignments []Assignment + for _, row := range findNodes(table, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "tr" + }) { + cells := directChildrenByTag(row, "td") + if len(cells) < 2 { + continue + } + link := findNode(cells[1], func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != "" + }) + if link == nil { + continue + } + assignments = append(assignments, Assignment{ + DueDate: inferUpcomingDate(nodeText(cells[0]), now), + Course: course, + Title: nodeText(link), + Status: "Upcoming", + Link: resolveURL(baseURL, attr(link, "href")), + Category: nodeText(findNode(cells[1], func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "small" })), + }) + } + sortAssignments(assignments) + return assignments, nil +} + +func fetchHTML(client *http.Client, rawURL string) (*html.Node, *url.URL, error) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, nil, err + } + req.Header.Set("User-Agent", userAgent) + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("fetch %s: %s", rawURL, resp.Status) + } + doc, err := html.Parse(resp.Body) + if err != nil { + return nil, nil, err + } + return doc, resp.Request.URL, nil +} + +func filterMissingAssignments(assignments []Assignment, startDate time.Time) []Assignment { + var filtered []Assignment + for _, assignment := range assignments { + if !assignment.DueDate.Before(startOfDay(startDate)) { + filtered = append(filtered, assignment) + } + } + return filtered +} + +func filterUpcomingAssignments(assignments []Assignment, now time.Time, days int) []Assignment { + start := startOfDay(now) + end := start.AddDate(0, 0, days) + var filtered []Assignment + for _, assignment := range assignments { + if !assignment.DueDate.Before(start) && !assignment.DueDate.After(end) { + filtered = append(filtered, assignment) + } + } + return filtered +} + +func dedupeAssignments(assignments []Assignment) []Assignment { + seen := map[string]struct{}{} + var deduped []Assignment + for _, assignment := range assignments { + key := assignment.Link + "|" + assignment.Title + "|" + assignment.DueDate.Format("2006-01-02") + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + deduped = append(deduped, assignment) + } + sortAssignments(deduped) + return deduped +} + +func buildSubject(missing, upcoming []Assignment, startDate time.Time, upcomingDays int) string { + return fmt.Sprintf( + "Alma report: %d missing since %s, %d due in %d days", + len(missing), + startDate.Format("2006-01-02"), + len(upcoming), + upcomingDays, + ) +} + +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")) + 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, " Status: %s\n", assignment.Status) + fmt.Fprintf(&b, " Link: %s\n", assignment.Link) + } + } + fmt.Fprintf(&b, "\nUpcoming In Next %d Days\n", upcomingDays) + b.WriteString("------------------------\n") + if len(upcoming) == 0 { + 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, " Category: %s\n", firstNonEmpty(assignment.Category, "Upcoming")) + fmt.Fprintf(&b, " Link: %s\n", assignment.Link) + } + } + fmt.Fprintf(&b, "\nAssignments page: %s\n", assignmentsURL) + fmt.Fprintf(&b, "Schedule page: %s\n", scheduleURL) + return b.String() +} + +func sendEmail(cfg Config, subject, body string) error { + address := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort) + conn, err := smtp.Dial(address) + if err != nil { + return err + } + defer conn.Close() + + host := cfg.SMTPHost + if cfg.SMTPStartTLS { + if ok, _ := conn.Extension("STARTTLS"); ok { + if err := conn.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { + return err + } + } + } + if cfg.SMTPUsername != "" { + auth := smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, host) + if err := conn.Auth(auth); err != nil { + return err + } + } + if err := conn.Mail(cfg.EmailFrom); err != nil { + return err + } + for _, recipient := range cfg.EmailTo { + if err := conn.Rcpt(recipient); err != nil { + return err + } + } + writer, err := conn.Data() + if err != nil { + return err + } + defer writer.Close() + + headers := textproto.MIMEHeader{} + headers.Set("From", cfg.EmailFrom) + headers.Set("To", strings.Join(cfg.EmailTo, ", ")) + headers.Set("Subject", subject) + headers.Set("MIME-Version", "1.0") + headers.Set("Content-Type", "text/plain; charset=UTF-8") + + for key, values := range headers { + for _, value := range values { + if _, err := fmt.Fprintf(writer, "%s: %s\r\n", key, value); err != nil { + return err + } + } + } + if _, err := io.WriteString(writer, "\r\n"+body); err != nil { + return err + } + return conn.Quit() +} + +func inferAssignmentDate(monthDay string, now time.Time) time.Time { + return inferDate(monthDay, now, false) +} + +func inferUpcomingDate(monthDay string, now time.Time) time.Time { + return inferDate(monthDay, now, true) +} + +func inferDate(monthDay string, now time.Time, future bool) time.Time { + candidate, err := time.Parse("Jan 02 2006", monthDay+" "+strconv.Itoa(now.Year())) + if err != nil { + return time.Time{} + } + candidate = startOfDay(candidate) + today := startOfDay(now) + if future { + if candidate.Before(today.AddDate(0, 0, -30)) { + return candidate.AddDate(1, 0, 0) + } + return candidate + } + if candidate.After(today.AddDate(0, 0, 30)) { + return candidate.AddDate(-1, 0, 0) + } + return candidate +} + +func startOfDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func sortAssignments(assignments []Assignment) { + slices.SortFunc(assignments, func(a, b Assignment) int { + if !a.DueDate.Equal(b.DueDate) { + if a.DueDate.Before(b.DueDate) { + return -1 + } + return 1 + } + if c := strings.Compare(strings.ToLower(a.Course), strings.ToLower(b.Course)); c != 0 { + return c + } + return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title)) + }) +} + +func resolveURL(baseURL *url.URL, href string) string { + if baseURL == nil || href == "" { + return "" + } + ref, err := url.Parse(href) + if err != nil { + return "" + } + return baseURL.ResolveReference(ref).String() +} + +func findNode(root *html.Node, match func(*html.Node) bool) *html.Node { + if root == nil { + return nil + } + if match(root) { + return root + } + for child := root.FirstChild; child != nil; child = child.NextSibling { + if found := findNode(child, match); found != nil { + return found + } + } + return nil +} + +func findNodes(root *html.Node, match func(*html.Node) bool) []*html.Node { + var nodes []*html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if n == nil { + return + } + if match(n) { + nodes = append(nodes, n) + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(root) + return nodes +} + +func directChildrenByTag(node *html.Node, tag string) []*html.Node { + var children []*html.Node + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == tag { + children = append(children, child) + } + } + return children +} + +func hasClass(node *html.Node, class string) bool { + for _, item := range strings.Fields(attr(node, "class")) { + if item == class { + return true + } + } + return false +} + +func ancestorHasClass(node *html.Node, class string) bool { + for current := node.Parent; current != nil; current = current.Parent { + if hasClass(current, class) { + return true + } + } + return false +} + +func parentIsTag(node *html.Node, tag string) bool { + return node != nil && node.Parent != nil && node.Parent.Type == html.ElementNode && node.Parent.Data == tag +} + +func attr(node *html.Node, key string) string { + if node == nil { + return "" + } + for _, attribute := range node.Attr { + if attribute.Key == key { + return attribute.Val + } + } + return "" +} + +func nodeText(node *html.Node) string { + if node == nil { + return "" + } + var parts []string + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.TextNode { + if text := strings.TrimSpace(n.Data); text != "" { + parts = append(parts, text) + } + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(node) + return strings.Join(parts, " ") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f923ca5 --- /dev/null +++ b/main_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "strings" + "testing" + "time" + + "golang.org/x/net/html" +) + +func TestInferAssignmentDate(t *testing.T) { + now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) + got := inferAssignmentDate("Jan 20", now) + want := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("got %s want %s", got, want) + } +} + +func TestInferAssignmentDateRollsBackYear(t *testing.T) { + now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) + got := inferAssignmentDate("Dec 15", now) + want := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("got %s want %s", got, want) + } +} + +func TestInferUpcomingDateRollsForwardYear(t *testing.T) { + now := time.Date(2026, 12, 28, 10, 0, 0, 0, time.UTC) + got := inferUpcomingDate("Jan 03", now) + want := time.Date(2027, 1, 3, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("got %s want %s", got, want) + } +} + +func TestFilterUpcomingAssignments(t *testing.T) { + now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) + assignments := []Assignment{ + {DueDate: time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC), Title: "Soon"}, + {DueDate: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Title: "Later"}, + } + got := filterUpcomingAssignments(assignments, now, 14) + if len(got) != 1 || got[0].Title != "Soon" { + 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), + Title: "Preparedness", + Link: "https://example.invalid/a", + } + got := dedupeAssignments([]Assignment{assignment, assignment}) + if len(got) != 1 { + t.Fatalf("got %d items", len(got)) + } +} + +func TestExtractLikeLiveMissingTable(t *testing.T) { + doc := mustParseHTML(` + + + + + + + + +
Mar
25
+ US History + HW: Timeline Events +
`) + + table := findNode(doc, func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "missing-assignments") + }) + if table == nil { + t.Fatal("missing table not found") + } + row := findNode(table, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "tr" }) + cells := directChildrenByTag(row, "td") + if got := nodeText(cells[0]); got != "Mar 25" { + t.Fatalf("got %q", got) + } + link := findNode(cells[1], func(n *html.Node) bool { + return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != "" + }) + if got := nodeText(link); got != "HW: Timeline Events" { + t.Fatalf("got %q", got) + } +} + +func mustParseHTML(text string) *html.Node { + doc, err := html.Parse(strings.NewReader(text)) + if err != nil { + panic(err) + } + return doc +}