- New /codes dashboard page (behind the session login, linked in the top bar):
lists every mapping by kind with Add, inline Edit+Save and Delete. Sam can put
real teacher surnames in without a rebuild (FR-17).
- Decode timetable EVENTS through the same map as periods, so an ensemble reads
'JAZZ ENSEMBLE - Ensemble Room - Mr KWO' instead of raw codes.
- teacherName splits comma- or space-separated codes ('HHL, OFG').
- SeedCodes is a one-time bootstrap while the table is empty, so a correction or
a delete is never overwritten or restored.
- Tests: decode Label/Event, the /codes page (save, refuse, csrf, delete, auth),
store re-seed behaviour. go test ./internal/... passes.
123 lines
3.8 KiB
Go
123 lines
3.8 KiB
Go
// Package decode turns a raw Compass lesson label into plain English using the
|
|
// editable codes map (AGENTS.md §4 FR-17). An unknown code renders as the raw
|
|
// code, never blank.
|
|
package decode
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
|
|
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
|
)
|
|
|
|
// Period renders one lesson title as plain English, for example
|
|
//
|
|
// "1 - 7SCF - H14LAB - KNI" -> "Period 1 — Year 7 Science — Room H14LAB — Mr KNI"
|
|
//
|
|
// A title that is not a lesson (a concert, an excursion) is returned unchanged.
|
|
// A code that is not in the map falls back to the raw code (FR-17).
|
|
func Period(title string, m store.CodeMap) string {
|
|
slot, subject, room, teacher, ok := period.Split(strings.TrimSpace(title))
|
|
if !ok {
|
|
return strings.TrimSpace(title)
|
|
}
|
|
|
|
parts := make([]string, 0, 4)
|
|
parts = append(parts, periodName(slot, m))
|
|
if subject != "" {
|
|
parts = append(parts, codeName(m, store.CodeSubject, subject))
|
|
}
|
|
if room != "" {
|
|
parts = append(parts, codeName(m, store.CodeRoom, room))
|
|
}
|
|
if teacher != "" {
|
|
parts = append(parts, teacherName(m, teacher))
|
|
}
|
|
return strings.Join(parts, " — ")
|
|
}
|
|
|
|
// teacherName decodes a teacher field. Compass sometimes carries several codes
|
|
// in the one field, separated by spaces ("GT SMH") or commas ("HHL, OFG"), so
|
|
// every token is decoded and joined. An unmapped token stays raw (FR-17).
|
|
func teacherName(m store.CodeMap, field string) string {
|
|
tokens := teacherTokens(field)
|
|
if len(tokens) == 0 {
|
|
return strings.TrimSpace(field)
|
|
}
|
|
out := make([]string, 0, len(tokens))
|
|
for _, tok := range tokens {
|
|
out = append(out, codeName(m, store.CodeTeacher, tok))
|
|
}
|
|
return strings.Join(out, " / ")
|
|
}
|
|
|
|
// teacherTokens splits a teacher field on commas and whitespace.
|
|
func teacherTokens(field string) []string {
|
|
return strings.Fields(strings.ReplaceAll(field, ",", " "))
|
|
}
|
|
|
|
// Label renders any Compass timetable label as plain English: a lesson label
|
|
// through Period, anything else (an ensemble, a concert, an excursion) through
|
|
// Event. It is the decoder the briefs use. An unmapped code stays raw (FR-17).
|
|
func Label(title string, m store.CodeMap) string {
|
|
if _, _, _, _, ok := period.Split(strings.TrimSpace(title)); ok {
|
|
return Period(title, m)
|
|
}
|
|
return Event(title, m)
|
|
}
|
|
|
|
// Event renders a timetable event label as plain English:
|
|
//
|
|
// "JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG" -> "JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG"
|
|
//
|
|
// The event name is kept as the school wrote it; the room and teacher codes are
|
|
// decoded. A label with no room or teacher is returned with just its name, and
|
|
// an unmapped code stays raw (FR-17).
|
|
func Event(title string, m store.CodeMap) string {
|
|
parts := period.SplitDash(strings.TrimSpace(title))
|
|
if len(parts) <= 1 {
|
|
return strings.TrimSpace(title)
|
|
}
|
|
out := []string{parts[0]}
|
|
if parts[1] != "" {
|
|
out = append(out, codeName(m, store.CodeRoom, parts[1]))
|
|
}
|
|
if len(parts) >= 3 && parts[2] != "" {
|
|
out = append(out, teacherName(m, parts[2]))
|
|
}
|
|
return strings.Join(out, " — ")
|
|
}
|
|
|
|
// periodName renders the period slot: a word label such as "C" uses the map
|
|
// ("Connect"), a number becomes "Period 3", and anything unknown stays raw.
|
|
func periodName(slot string, m store.CodeMap) string {
|
|
if name := strings.TrimSpace(m.Lookup(store.CodePeriod, slot)); name != "" {
|
|
return name
|
|
}
|
|
if isNumber(slot) {
|
|
return "Period " + slot
|
|
}
|
|
return slot
|
|
}
|
|
|
|
// codeName returns the mapped name, or the raw code when it is not mapped.
|
|
func codeName(m store.CodeMap, kind, code string) string {
|
|
if name := strings.TrimSpace(m.Lookup(kind, code)); name != "" {
|
|
return name
|
|
}
|
|
return code
|
|
}
|
|
|
|
func isNumber(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if !unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|