commit 5d1be582b52eebc84c5a1c60df90486a69c62c64 Author: Joe Julian Date: Sat Mar 28 11:06:40 2026 -0700 Initial Alma assignments reporter 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 +}