- Parse each child's own "Today" day widget for the timetable (FR-16); drop the school-wide week calendar's lesson grid and clean the leaked routine rows. - Add the editable codes map (FR-17): subjects, rooms, teachers and period labels, seeded, an unknown code falls back to the raw code; `school codes` CLI. - Brief: Finn's block first, then Harry's, four sections always present (FR-9); periods decoded into plain English; the undated tail is one counted line (FR-18). - Calendar: write periods + work due + work overdue + major events, per child, on every pass. Identity is the stable per-item key, so a re-run never re-inserts (FR-8). morning-brief.sh now runs scan -> calendar push -> notify. - Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods. Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing. Co-Authored-By: Paperclip <noreply@paperclip.ing>
170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
package parse
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
|
|
)
|
|
|
|
// calendarPxPerHour is the Compass week-grid scale: the day gutter is 1200px
|
|
// tall for 24 hours, so 50px is one hour. The event's top offset is therefore
|
|
// the authoritative hour; the printed "6:00" prefix is 12-hour and ambiguous.
|
|
const calendarPxPerHour = 50.0
|
|
|
|
var (
|
|
// calendarDayCol matches the day column id, for example
|
|
// calendar-manager-week-bd-day-col-20261016.
|
|
calendarDayCol = regexp.MustCompile(`calendar-manager-week-bd-day-col-(\d{8})`)
|
|
// calendarTop matches the top offset in an event's inline style.
|
|
calendarTop = regexp.MustCompile(`top:\s*([0-9.]+)px`)
|
|
// clockPrefix matches the leading "8:55: " of an event label.
|
|
clockPrefix = regexp.MustCompile(`^(\d{1,2}):(\d{2}):\s*(.*)$`)
|
|
)
|
|
|
|
// parseCalendar parses the week calendar page. The week calendar is
|
|
// school-wide and its lesson grid belongs to the eldest child, so routine
|
|
// lessons are dropped here: each child's own periods come from that child's
|
|
// day widget (FR-16). Only real school events are kept.
|
|
func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error) {
|
|
items, found, err := extractCalendar(child, page, loc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("parse: %s: no calendar grid found in %s (layout changed or fetch failed)", child, page.Slug)
|
|
}
|
|
out := items[:0]
|
|
for _, it := range items {
|
|
if eventIsSchoolEvent(it.Title) {
|
|
out = append(out, it)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// extractCalendar returns the calendar events in one page. found reports whether
|
|
// the page carried a week grid at all.
|
|
func extractCalendar(child string, page Page, loc *time.Location) (items []Item, found bool, err error) {
|
|
root := parseHTML(page.HTML)
|
|
|
|
dayCols := findAll(root, func(n *node) bool {
|
|
return n.Tag == "div" && calendarDayCol.MatchString(n.Attrs["id"])
|
|
})
|
|
events := findAll(root, func(n *node) bool {
|
|
return n.Tag == "div" && n.hasClassPart("calendar-manager-week-bd-evt-")
|
|
})
|
|
found = len(dayCols) > 0 || len(events) > 0
|
|
if !found {
|
|
return nil, false, nil
|
|
}
|
|
|
|
for _, ev := range events {
|
|
it, ok, err := eventItem(child, ev, page, loc)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if ok {
|
|
items = append(items, it)
|
|
}
|
|
}
|
|
return items, true, nil
|
|
}
|
|
|
|
func eventItem(child string, ev *node, page Page, loc *time.Location) (Item, bool, error) {
|
|
labelSpan := find(ev, func(n *node) bool { return n.hasClass("ext-evt-bd") })
|
|
if labelSpan == nil {
|
|
return Item{}, false, nil // not an event body
|
|
}
|
|
label := collapse(labelSpan.Text())
|
|
if label == "" {
|
|
return Item{}, false, nil
|
|
}
|
|
|
|
day := nearest(ev, func(n *node) bool {
|
|
return n.Tag == "div" && calendarDayCol.MatchString(n.Attrs["id"])
|
|
})
|
|
if day == nil {
|
|
return Item{}, false, fmt.Errorf("parse: %s: event %q has no day column", page.Slug, label)
|
|
}
|
|
m := calendarDayCol.FindStringSubmatch(day.Attrs["id"])
|
|
date, err := time.ParseInLocation("20060102", m[1], loc)
|
|
if err != nil {
|
|
return Item{}, false, fmt.Errorf("parse: %s: event %q: bad day column %q", page.Slug, label, day.Attrs["id"])
|
|
}
|
|
|
|
topMatch := calendarTop.FindStringSubmatch(ev.Attrs["style"])
|
|
if topMatch == nil {
|
|
return Item{}, false, fmt.Errorf("parse: %s: event %q has no top offset", page.Slug, label)
|
|
}
|
|
topPx, err := strconv.ParseFloat(topMatch[1], 64)
|
|
if err != nil {
|
|
return Item{}, false, fmt.Errorf("parse: %s: event %q: bad top offset %q", page.Slug, label, topMatch[1])
|
|
}
|
|
|
|
// The hour comes from the grid offset; the minutes come from the label.
|
|
hour := int(topPx / calendarPxPerHour)
|
|
minute := 0
|
|
title := label
|
|
if cm := clockPrefix.FindStringSubmatch(label); cm != nil {
|
|
if v, err := strconv.Atoi(cm[2]); err == nil {
|
|
minute = v
|
|
}
|
|
title = strings.TrimSpace(cm[3])
|
|
}
|
|
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
|
return Item{}, false, fmt.Errorf("parse: %s: event %q: bad time %02d:%02d", page.Slug, label, hour, minute)
|
|
}
|
|
at := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc)
|
|
|
|
subject, teacher, body := splitEvent(label, title)
|
|
|
|
url := page.URL
|
|
if url == "" {
|
|
url = compassHost + "/Organise/Calendar/"
|
|
}
|
|
|
|
return Item{
|
|
Child: child,
|
|
Source: SourceCompass,
|
|
Type: TypeEvent,
|
|
Subject: subject,
|
|
Teacher: teacher,
|
|
Title: title,
|
|
Body: body,
|
|
DueAt: &at,
|
|
URL: url,
|
|
SourcePage: page.Slug,
|
|
ExternalRef: dedupeKey(at.Format(time.RFC3339), title),
|
|
Importance: importanceNormal,
|
|
}, true, nil
|
|
}
|
|
|
|
// splitEvent breaks a calendar label into subject, teacher and body. A lesson
|
|
// label is "5 - 11HLEGB5 - D8 - LHT"; a school event is
|
|
// "End of Year Senior Music Concert - HALL (...) - KSH, CFR, ...".
|
|
func splitEvent(label, title string) (subject, teacher, body string) {
|
|
parts := period.SplitDash(title)
|
|
if len(parts) >= 2 && period.IsPeriodLabel(parts[0]) {
|
|
for _, p := range parts[1:] {
|
|
if subject == "" && classCode.MatchString(p) {
|
|
subject = p
|
|
}
|
|
}
|
|
if !classCode.MatchString(parts[0]) {
|
|
teacher = parts[len(parts)-1]
|
|
}
|
|
}
|
|
body = label
|
|
if teacher != "" {
|
|
body = label + "\nTeacher(s): " + teacher
|
|
}
|
|
return subject, teacher, body
|
|
}
|
|
|
|
// splitDash splits on " - " and trims each part.
|
|
func splitDash(s string) []string { return period.SplitDash(s) }
|