837 lines
22 KiB
Go
837 lines
22 KiB
Go
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
|
|
SMTPHeloName string
|
|
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", false)
|
|
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)
|
|
smtpHeloName, err := readValue("SMTP_HELO_NAME", "", false)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
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,
|
|
SMTPHeloName: normalizeSMTPHeloName(smtpHeloName),
|
|
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) {
|
|
if envUser, _ := readValue("ALMA_USERNAME", "", false); envUser != "" {
|
|
if envPass, _ := readValue("ALMA_PASSWORD", "", false); envPass != "" {
|
|
return map[string]string{
|
|
"username": envUser,
|
|
"password": envPass,
|
|
}, nil
|
|
}
|
|
return nil, errors.New("ALMA_PASSWORD is required when ALMA_USERNAME is set")
|
|
}
|
|
|
|
if filePath == "" {
|
|
return nil, errors.New("missing Alma credentials: set ALMA_USERNAME/ALMA_PASSWORD or ALMA_CREDS_FILE")
|
|
}
|
|
|
|
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 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
|
|
heloName := normalizeSMTPHeloName(cfg.SMTPHeloName)
|
|
if err := conn.Hello(heloName); err != nil {
|
|
return err
|
|
}
|
|
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 err := conn.Hello(heloName); 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 normalizeSMTPHeloName(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
hostname, err := os.Hostname()
|
|
if err == nil {
|
|
value = strings.TrimSpace(hostname)
|
|
}
|
|
}
|
|
if value == "" {
|
|
return "localhost.localdomain"
|
|
}
|
|
if strings.Contains(value, ".") {
|
|
return value
|
|
}
|
|
return value + ".localdomain"
|
|
}
|
|
|
|
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 ""
|
|
}
|