package parse import ( "encoding/json" "fmt" "regexp" "strconv" "strings" "time" ) // This file parses the authoritative per-child learning-task list from the // Compass JSON service behind the Learning Tasks grid // (/Services/LearningTasks.svc/GetAllLearningTasksByUserId). The grid itself is // capped at LearningTask_Default_Records_Count (20) rows and, on the parent // page, mixes children, so it cannot answer "what is overdue" (AUD-114, FR-12). // Learning-task submission status codes, from // Compass.enums.LearningTasksSubmissionStatus in the Compass client bundle. const ( submissionPending = 1 submissionOverdue = 2 submissionOnTime = 3 submissionSubmittedLate = 4 ) // overdueBodyMarker is the status line the JSON parser writes into a task body. // The dashboard and the offline importance rule key off it. const overdueBodyMarker = "Status: Overdue" // tasksResponse is the envelope Compass returns: {"d":{"data":[...],"total":n}}. type tasksResponse struct { D struct { Data []taskRecord `json:"data"` Total *int `json:"total"` } `json:"d"` } // taskRecord is one learning task. dueDateTimestamp is set on the task for most // rows; a few rows carry it on the student instead, so both are read. type taskRecord struct { ID int `json:"id"` Name string `json:"name"` ActivityName string `json:"activityName"` SubjectName string `json:"subjectName"` GroupName string `json:"groupName"` DueDateTimestamp string `json:"dueDateTimestamp"` Students []taskStudent `json:"students"` } // taskStudent is one child's row within a task: their submission status and, // sometimes, the due date. type taskStudent struct { UserID int `json:"userId"` UserName string `json:"userName"` SubmissionStatus *int `json:"submissionStatus"` DueDateTimestamp string `json:"dueDateTimestamp"` SubmittedTimestamp string `json:"submittedTimestamp"` } // parsedTasks is the result of reading one child's authoritative task list. type parsedTasks struct { items []Item total int overdue int userID string } // parseLearningTasksAll turns the stored JSON service response into task items. // It fails loudly when the body is missing, is not the expected envelope, is // missing the child's userId, or carries a task without a status or title, // because a partial list must never be shown as "nothing overdue" (FR-12). func parseLearningTasksAll(child string, page Page, loc *time.Location) (parsedTasks, error) { out := parsedTasks{userID: strings.TrimSpace(page.UserID)} if len(page.JSON) == 0 { return out, fmt.Errorf("parse: %s: %s has no stored JSON body", child, page.Slug) } var resp tasksResponse if err := json.Unmarshal(page.JSON, &resp); err != nil { return out, fmt.Errorf("parse: %s: %s is not a Compass learning-tasks response: %w", child, page.Slug, err) } if resp.D.Data == nil { return out, fmt.Errorf("parse: %s: %s carried no task list (session expired or layout changed)", child, page.Slug) } if out.userID == "" { return out, fmt.Errorf("parse: %s: %s is missing the child's Compass userId; cannot select the child's tasks", child, page.Slug) } out.total = len(resp.D.Data) if resp.D.Total != nil && *resp.D.Total != out.total { return out, fmt.Errorf("parse: %s: %s returned %d of %d tasks; refusing a partial list (FR-12)", child, page.Slug, out.total, *resp.D.Total) } for _, t := range resp.D.Data { st, ok := studentFor(t, out.userID) if !ok { continue // the parent endpoint returns siblings too; keep only this child } if st.SubmissionStatus == nil { return out, fmt.Errorf("parse: %s: learning task %d has no submission status", child, t.ID) } status := *st.SubmissionStatus if status == submissionOverdue { out.overdue++ } title := collapse(t.Name) if title == "" { return out, fmt.Errorf("parse: %s: learning task %d has no title", child, t.ID) } due, err := parseCompassTimestamp(firstNonEmpty(t.DueDateTimestamp, st.DueDateTimestamp), loc) if err != nil { return out, fmt.Errorf("parse: %s: learning task %d: %w", child, t.ID, err) } body := []string{"Status: " + submissionStatusLabel(status)} if a := collapse(t.ActivityName); a != "" { body = append(body, "Class: "+a) } if due != nil { body = append(body, "Due: "+due.Format("2006-01-02 15:04")) } if s := strings.TrimSpace(st.SubmittedTimestamp); s != "" { body = append(body, "Submitted: "+s) } out.items = append(out.items, Item{ Child: child, Source: SourceCompass, Type: TypeTask, Subject: collapse(firstNonEmpty(t.SubjectName, t.GroupName, t.ActivityName)), Title: title, Body: strings.Join(body, "\n"), DueAt: due, URL: memberLearningTasksURL(out.userID), SourcePage: page.Slug, ExternalRef: dedupeKey("learning-task", strconv.Itoa(t.ID)), Importance: importanceNormal, }) } return out, nil } // studentFor returns the child's row within a task by Compass userId. func studentFor(t taskRecord, userID string) (taskStudent, bool) { for _, s := range t.Students { if strconv.Itoa(s.UserID) == userID { return s, true } } return taskStudent{}, false } // submissionStatusLabel maps a Compass status code to the label the grid shows. func submissionStatusLabel(code int) string { switch code { case submissionPending: return "Pending" case submissionOverdue: return "Overdue" case submissionOnTime: return "On Time" case submissionSubmittedLate: return "Received Late" default: return fmt.Sprintf("status %d", code) } } // parseCompassTimestamp parses the service's RFC3339 UTC due timestamp in loc. // An empty value means no due date; anything else that does not parse is an // error, never a silent zero (FR-12). func parseCompassTimestamp(s string, loc *time.Location) (*time.Time, error) { s = strings.TrimSpace(s) if s == "" { return nil, nil } t, err := time.Parse(time.RFC3339, s) if err != nil { return nil, fmt.Errorf("unrecognised due timestamp %q", s) } if loc == nil { loc = time.UTC } t = t.In(loc) return &t, nil } // overdueLinkRe reads the count from a home-page link such as // "6 overdue learning tasks". var overdueLinkRe = regexp.MustCompile(`(\d+)\s+overdue learning task`) // homeOverdueCount returns the overdue count the parent home page announces for // one child. found is false when the page carries no record of that child at // all (a login or challenge page), so a missing summary is never read as zero // (FR-12). A child with no overdue line reports 0. func homeOverdueCount(homeHTML []byte, userID string) (count int, found bool) { if strings.TrimSpace(userID) == "" { return 0, false } root := parseHTML(homeHTML) seenChild := false count = -1 for _, a := range findAll(root, func(n *node) bool { return n.Tag == "a" }) { if !strings.Contains(a.Attrs["href"], "userId="+userID) { continue } seenChild = true if m := overdueLinkRe.FindStringSubmatch(collapse(a.Text())); m != nil { if n, err := strconv.Atoi(m[1]); err == nil { count = n } } } if count >= 0 { return count, true } return 0, seenChild } // HomeOverdueCount reports the overdue learning-task count the home page // announces for one child. It is exported so a command can show the number it // reconciled against. func HomeOverdueCount(homeHTML []byte, userID string) (int, bool) { return homeOverdueCount(homeHTML, userID) } // OverdueCount returns how many learning tasks carry the Overdue submission // status. The JSON parser writes the status into the item body, so the count is // exact and does not depend on the wall clock. func OverdueCount(items []Item) int { n := 0 for _, it := range items { if it.Type == TypeTask && strings.Contains(it.Body, overdueBodyMarker) { n++ } } return n } // memberLearningTasksURL links a task back to the child's profile grid. func memberLearningTasksURL(userID string) string { if strings.TrimSpace(userID) == "" { return compassHost + "/Records/UserNew.aspx#learningTasks" } return compassHost + "/Records/User.aspx?userId=" + userID + "#learningTasks" } // firstNonEmpty returns the first value with non-whitespace content. func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" }