Files
Sam Rolfe 047af0f31d 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>
2026-10-16 10:25:55 +11:00

238 lines
6.9 KiB
Go

package parse
import (
"fmt"
"sort"
"strings"
"time"
)
// Source and item types match the items table CHECK constraints (AGENTS.md §7).
const (
SourceCompass = "compass"
TypeMessage = "message"
TypeTask = "task"
TypeEvent = "event"
)
// Recognised page slugs, matching the <slug>.html files stored by the Phase 2
// fetchers under var/raw/<child>/<runId>/.
const (
PageHome = "home"
PageChildProfile = "child-profile"
PageLearningTasks = "learning-tasks"
PageLearningTasksAll = "learning-tasks-all"
PageCalendar = "calendar"
)
// Item is one message, learning task or calendar event read from Compass.
// Every field maps to a column of the items table (FR-5).
type Item struct {
Child string `json:"child"`
Source string `json:"source"`
Type string `json:"type"`
Subject string `json:"subject,omitempty"`
Teacher string `json:"teacher,omitempty"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
DueAt *time.Time `json:"due_at,omitempty"`
URL string `json:"url,omitempty"`
SourcePage string `json:"source_page"`
ExternalRef string `json:"external_ref"`
Importance string `json:"importance"`
}
// Page is one stored raw page: its slug, the URL it was fetched from, and the
// bytes. HTML pages carry HTML; the JSON service page carries JSON and the
// child's Compass userId (from the fetcher's meta file). FetchedAt is when the
// fetcher stored the page, so a "Today" widget can be dated correctly even when
// an old run is re-parsed.
type Page struct {
Slug string
URL string
HTML []byte
JSON []byte
UserID string
FetchedAt time.Time
}
// Parse turns the stored pages for one child into structured items. It fails,
// loudly and explicitly, when a page it recognises cannot be parsed, so a broken
// scan can never be shown as "nothing due" (FR-12).
//
// Pages with an unknown slug are ignored. When no recognised page yields any
// structure at all, Parse returns an error.
func Parse(child string, pages []Page, loc *time.Location) ([]Item, error) {
if strings.TrimSpace(child) == "" {
return nil, fmt.Errorf("parse: child is empty")
}
if loc == nil {
loc = time.UTC
}
ordered := pagesOf(pages)
// When the authoritative per-child task list is stored, it supersedes the
// legacy HTML grid: the grid is capped and, on the parent page, mixes
// children. Parse the JSON list and ignore the grid pages for tasks so the
// same task is never stored twice.
hasAuthoritativeTasks := false
for _, p := range ordered {
if p.Slug == PageLearningTasksAll {
hasAuthoritativeTasks = true
}
}
var items []Item
recognised := 0
var tasks *parsedTasks
for i := range ordered {
p := ordered[i]
switch p.Slug {
case PageLearningTasksAll:
got, err := parseLearningTasksAll(child, p, loc)
if err != nil {
return nil, err
}
tasks = &got
items = append(items, got.items...)
recognised++
case PageLearningTasks:
if !hasAuthoritativeTasks {
got, err := parseLearningTasks(child, p, loc)
if err != nil {
return nil, err
}
items = append(items, got...)
}
// The child's own day widget is the per-child timetable (FR-16).
day, _, err := parseDayWidget(child, p, loc, p.FetchedAt)
if err != nil {
return nil, err
}
items = append(items, day...)
recognised++
case PageChildProfile:
// The React profile page has no server-rendered grid; when it does
// carry the legacy grid, use it. The JSON list supersedes it.
if !hasAuthoritativeTasks {
got, found, err := extractLearningTasks(child, p, loc)
if err != nil {
return nil, err
}
if found {
items = append(items, got...)
}
}
// The profile page carries the child's own "Today" widget: the
// authoritative per-child periods (FR-16).
day, _, err := parseDayWidget(child, p, loc, p.FetchedAt)
if err != nil {
return nil, err
}
items = append(items, day...)
recognised++
case PageHome:
got, err := parseNews(child, p, loc)
if err != nil {
return nil, err
}
items = append(items, got...)
recognised++
case PageCalendar:
got, err := parseCalendar(child, p, loc)
if err != nil {
return nil, err
}
items = append(items, got...)
recognised++
}
}
if recognised == 0 {
return nil, fmt.Errorf("parse: no recognised Compass page in %d stored page(s)", len(pages))
}
// FR-12: the stored task list must agree with the summary on the home page.
// A partial or stale list must never be shown as "nothing overdue", so a
// disagreement stops the parse instead of writing a quiet wrong answer.
if tasks != nil {
home := findPage(ordered, PageHome)
if home == nil {
return nil, fmt.Errorf("parse: %s: %s is present but the home page is missing; cannot verify the overdue count (FR-12)", child, PageLearningTasksAll)
}
want, found := homeOverdueCount(home.HTML, tasks.userID)
if !found {
return nil, fmt.Errorf("parse: %s: home page has no record of Compass user %s; cannot verify the overdue count (FR-12)", child, tasks.userID)
}
if want != tasks.overdue {
return nil, fmt.Errorf("parse: %s: home page reports %d overdue learning task(s) but the stored learning-task list has %d; refusing to store a partial list (FR-12)", child, want, tasks.overdue)
}
}
// A task row is the same task wherever it appears (learning tasks and child
// profile pages both carry the grid). Keep the first, drop the repeats.
seen := map[string]bool{}
out := items[:0]
for _, it := range items {
key := it.Type + "\x1f" + it.ExternalRef
if seen[key] {
continue
}
seen[key] = true
if it.Importance == "" {
it.Importance = importanceNormal
}
out = append(out, it)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].ExternalRef < out[j].ExternalRef
})
return out, nil
}
// findPage returns the stored page with this slug, or nil.
func findPage(pages []Page, slug string) *Page {
for i := range pages {
if pages[i].Slug == slug {
return &pages[i]
}
}
return nil
}
// pagesOf returns the pages with duplicates removed, keeping the last copy of a
// slug (the fetcher overwrites a slug file on a re-fetch, so the last is
// authoritative).
func pagesOf(pages []Page) []Page {
bySlug := map[string]Page{}
var order []string
for _, p := range pages {
if _, ok := bySlug[p.Slug]; !ok {
order = append(order, p.Slug)
}
bySlug[p.Slug] = p
}
out := make([]Page, 0, len(order))
for _, slug := range order {
out = append(out, bySlug[slug])
}
return out
}
// dedupeKey joins the identity fields of an item into a stable external
// reference. Re-parsing the same page must update the same row, not insert a
// second one.
func dedupeKey(parts ...string) string {
clean := make([]string, 0, len(parts))
for _, p := range parts {
clean = append(clean, collapse(p))
}
return strings.Join(clean, "|")
}