Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- 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>
This commit is contained in:
114
internal/parse/daywidget.go
Normal file
114
internal/parse/daywidget.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package parse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file parses the per-child day widget that Compass renders on a child's
|
||||
// own pages ("/Records/User.aspx?userId=..." and the Learning Tasks page). The
|
||||
// widget is labelled "Today" and its events are that child's own periods:
|
||||
//
|
||||
// 8:55: 1 - 7SCF - H14LAB - KNI
|
||||
//
|
||||
// The week calendar is school-wide (it carries the eldest child's lesson grid),
|
||||
// so it must never be the source of a child's timetable (AGENTS.md §4 FR-16).
|
||||
// This is the per-child source.
|
||||
|
||||
// dayWidgetBaseHour is unused: the widget's grid start moves with the day's
|
||||
// first event (a page whose first event is 7:45 starts at 07:00, one whose
|
||||
// first event is 8:55 starts at 06:00), so the printed clock is the reliable
|
||||
// source and the grid offset is not.
|
||||
|
||||
// parseDayWidget returns the periods in one child's day widget. found reports
|
||||
// whether the page carried a day widget at all.
|
||||
func parseDayWidget(child string, page Page, loc *time.Location, ref time.Time) (items []Item, found bool, err error) {
|
||||
root := parseHTML(page.HTML)
|
||||
events := findAll(root, func(n *node) bool {
|
||||
return n.Tag == "div" && n.hasClassPart("day-bd-evt-") && n.hasClassPart("calendardaywidget-")
|
||||
})
|
||||
if len(events) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
day := ref.In(loc)
|
||||
if day.IsZero() {
|
||||
day = time.Now().In(loc)
|
||||
}
|
||||
date := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
for _, ev := range events {
|
||||
labelSpan := find(ev, func(n *node) bool { return n.hasClass("ext-evt-bd") })
|
||||
if labelSpan == nil {
|
||||
continue
|
||||
}
|
||||
label := collapse(labelSpan.Text())
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
cm := clockPrefix.FindStringSubmatch(label)
|
||||
if cm == nil {
|
||||
return nil, true, fmt.Errorf("parse: %s: day-widget event %q has no start time", page.Slug, label)
|
||||
}
|
||||
at, err := dayWidgetTime(cm, date)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("parse: %s: day-widget event %q: %w", page.Slug, label, err)
|
||||
}
|
||||
title := strings.TrimSpace(cm[3])
|
||||
if title == "" {
|
||||
title = label
|
||||
}
|
||||
|
||||
subject, teacher, body := splitEvent(label, title)
|
||||
url := page.URL
|
||||
if url == "" {
|
||||
url = compassHost + "/Organise/Calendar/"
|
||||
}
|
||||
|
||||
items = append(items, Item{
|
||||
Child: child,
|
||||
Source: SourceCompass,
|
||||
Type: TypeEvent,
|
||||
Subject: subject,
|
||||
Teacher: teacher,
|
||||
Title: title,
|
||||
Body: body,
|
||||
DueAt: &at,
|
||||
URL: url,
|
||||
SourcePage: page.Slug,
|
||||
ExternalRef: dedupeKey("day-period", at.Format(time.RFC3339), title),
|
||||
Importance: importanceNormal,
|
||||
})
|
||||
}
|
||||
return items, true, nil
|
||||
}
|
||||
|
||||
// dayWidgetTime reads an event's start from the printed clock. Compass prints a
|
||||
// 12-hour clock with no AM/PM marker ("1:26" is the afternoon period), so the
|
||||
// school-day convention applies: 1-6 are PM, 7-11 are AM, 12 is noon.
|
||||
func dayWidgetTime(clock []string, date time.Time) (time.Time, error) {
|
||||
hour, minute := atoiSafe(clock[1]), atoiSafe(clock[2])
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return time.Time{}, fmt.Errorf("bad time %02d:%02d", hour, minute)
|
||||
}
|
||||
if hour >= 1 && hour <= 6 { // 1:26 means 1:26 PM in the Compass day grid
|
||||
hour += 12
|
||||
}
|
||||
return date.Add(time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute), nil
|
||||
}
|
||||
|
||||
// atoiSafe parses a small decimal number, returning -1 when it is not one.
|
||||
func atoiSafe(s string) int {
|
||||
n := 0
|
||||
if s == "" {
|
||||
return -1
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return -1
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user