// 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 }