From 5a722d16c68d9c0bf93f4191fcb5466d75feeafa Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 26 Aug 2025 11:39:30 -0500 Subject: [PATCH] docs: add trivial documentation for all types, functions, packages etc. --- cmd/banner/main.go | 2 + internal/api/api.go | 73 ++++++++++++++--------------- internal/api/scrape.go | 23 +++++---- internal/api/search.go | 44 ++++++++--------- internal/api/session.go | 1 + internal/api/term.go | 31 ++++-------- internal/bot/commands.go | 9 +++- internal/bot/handlers.go | 1 + internal/bot/state.go | 5 ++ internal/config/config.go | 34 +++++++++----- internal/config/logging.go | 23 ++++----- internal/errors.go | 1 + internal/helpers.go | 58 ++++++++++++----------- internal/meta.go | 5 +- internal/models/types.go | 96 +++++++++++++++++++------------------- 15 files changed, 211 insertions(+), 195 deletions(-) diff --git a/cmd/banner/main.go b/cmd/banner/main.go index 1a623c9..8f7c141 100644 --- a/cmd/banner/main.go +++ b/cmd/banner/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for the banner application. package main import ( @@ -73,6 +74,7 @@ func init() { discordgo.Logger = internal.DiscordGoLogger } +// initRedis initializes the Redis client and pings the server to ensure a connection. func initRedis(cfg *config.Config) { // Setup redis redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL") diff --git a/internal/api/api.go b/internal/api/api.go index ff1f6c3..30a9cb0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -20,10 +20,12 @@ import ( "resty.dev/v3" ) +// API provides a client for interacting with the Banner API. type API struct { config *config.Config } +// New creates a new API client with the given configuration. func New(config *config.Config) *API { return &API{config: config} } @@ -34,7 +36,7 @@ var ( expiryTime = 25 * time.Minute ) -// SessionMiddleware creates a Resty middleware that resets the session timer on each Banner API call. +// SessionMiddleware creates a Resty middleware that resets the session timer on each successful Banner API call. func SessionMiddleware(c *resty.Client, r *resty.Response) error { // log.Debug().Str("url", r.Request.RawRequest.URL.Path).Msg("Session middleware") @@ -48,8 +50,8 @@ func SessionMiddleware(c *resty.Client, r *resty.Response) error { return nil } -// GenerateSession generates a new session ID (nonce) for use with the Banner API. -// Don't use this function directly, use GetSession instead. +// GenerateSession generates a new session ID for use with the Banner API. +// This function should not be used directly; use EnsureSession instead. func GenerateSession() string { return internal.RandomString(5) + internal.Nonce() } @@ -57,7 +59,7 @@ func GenerateSession() string { var terms []BannerTerm var lastTermUpdate time.Time -// TryReloadTerms attempts to reload the terms if they are not loaded or the last update was more than 24 hours ago +// TryReloadTerms attempts to reload the terms if they are not loaded or if the last update was more than 24 hours ago. func (a *API) TryReloadTerms() error { if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour { return nil @@ -74,8 +76,9 @@ func (a *API) TryReloadTerms() error { return nil } -// IsTermArchived checks if the given term is archived -// TODO: Add error, switch missing term logic to error +// IsTermArchived checks if the given term is archived (view only). +// +// TODO: Add error handling for when a term does not exist. func (a *API) IsTermArchived(term string) bool { // Ensure the terms are loaded err := a.TryReloadTerms() @@ -106,21 +109,25 @@ func (a *API) EnsureSession() string { return latestSession } +// Pair represents a key-value pair from the Banner API. type Pair struct { Code string `json:"code"` Description string `json:"description"` } +// BannerTerm represents a term in the Banner system. type BannerTerm Pair + +// Instructor represents an instructor in the Banner system. type Instructor Pair -// Archived returns true if the term is in it's archival state (view only) +// Archived returns true if the term is in an archival (view-only) state. func (term BannerTerm) Archived() bool { return strings.Contains(term.Description, "View Only") } -// GetTerms retrieves and parses the term information for a given search term. -// Page number must be at least 1. +// GetTerms retrieves a list of terms from the Banner API. +// The page number must be at least 1. func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) { // Ensure offset is valid if page <= 0 { @@ -148,8 +155,8 @@ func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, e return *terms, nil } -// SelectTerm selects the given term in the Banner system. -// This function completes the initial term selection process, which is required before any other API calls can be made with the session ID. +// SelectTerm selects a term in the Banner system for the given session. +// This is required before other API calls can be made. func (a *API) SelectTerm(term string, sessionID string) error { form := url.Values{ "term": {term}, @@ -192,8 +199,8 @@ func (a *API) SelectTerm(term string, sessionID string) error { return nil } -// GetPartOfTerms retrieves and parses the part of term information for a given term. -// Ensure that the offset is greater than 0. +// GetPartOfTerms retrieves a list of parts of a term from the Banner API. +// The page number must be at least 1. func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) { // Ensure offset is valid if offset <= 0 { @@ -223,10 +230,7 @@ func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int return *terms, nil } -// GetInstructors retrieves and parses the instructor information for a given search term. -// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term. -// This function is included for completeness, but probably isn't useful. -// Ensure that the offset is greater than 0. +// GetInstructors retrieves a list of instructors from the Banner API. func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) { // Ensure offset is valid if offset <= 0 { @@ -256,11 +260,13 @@ func (a *API) GetInstructors(search string, term string, offset int, maxResults return *instructors, nil } -// ClassDetails represents the details of a course. -// TODO: Finish this struct & function +// ClassDetails represents the detailed information for a class. +// +// TODO: Implement this struct and the associated GetCourseDetails function. type ClassDetails struct { } +// GetCourseDetails retrieves the details for a specific course. func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) { body, err := json.Marshal(map[string]string{ "term": strconv.Itoa(term), @@ -289,7 +295,7 @@ func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) { return details, nil } -// Search invokes a search on the Banner system with the given query and returns the results. +// Search performs a search for courses with the given query and returns the results. func (a *API) Search(term string, query *Query, sort string, sortDescending bool) (*models.SearchResult, error) { a.ResetDataForm() @@ -322,9 +328,8 @@ func (a *API) Search(term string, query *Query, sort string, sortDescending bool return searchResult, nil } -// GetSubjects retrieves and parses the subject information for a given search term. -// The results of this response shouldn't change much, but technically could as new majors are developed, or old ones are removed. -// Ensure that the offset is greater than 0. +// GetSubjects retrieves a list of subjects from the Banner API. +// The page number must be at least 1. func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) { // Ensure offset is valid if offset <= 0 { @@ -354,10 +359,8 @@ func (a *API) GetSubjects(search string, term string, offset int, maxResults int return *subjects, nil } -// GetCampuses retrieves and parses the campus information for a given search term. -// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term. -// This function is included for completeness, but probably isn't useful. -// Ensure that the offset is greater than 0. +// GetCampuses retrieves a list of campuses from the Banner API. +// The page number must be at least 1. func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) { // Ensure offset is valid if offset <= 0 { @@ -387,10 +390,8 @@ func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ( return *campuses, nil } -// GetInstructionalMethods retrieves and parses the instructional method information for a given search term. -// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term. -// This function is included for completeness, but probably isn't useful. -// Ensure that the offset is greater than 0. +// GetInstructionalMethods retrieves a list of instructional methods from the Banner API. +// The page number must be at least 1. func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) { // Ensure offset is valid if offset <= 0 { @@ -419,9 +420,7 @@ func (a *API) GetInstructionalMethods(search string, term string, offset int, ma return *methods, nil } -// GetCourseMeetingTime retrieves the meeting time information for a course based on the given term and course reference number (CRN). -// It makes an HTTP GET request to the appropriate API endpoint and parses the response to extract the meeting time data. -// The function returns a MeetingTimeResponse struct containing the extracted information. +// GetCourseMeetingTime retrieves the meeting time information for a course. func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) { type responseWrapper struct { Fmt []models.MeetingTimeResponse `json:"fmt"` @@ -446,7 +445,8 @@ func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeRespo return result.Fmt, nil } -// ResetDataForm makes a POST request that needs to be made upon before new search requests can be made. +// ResetDataForm resets the search form in the Banner system. +// This must be called before a new search can be performed. func (a *API) ResetDataForm() { req := a.config.Client.NewRequest() @@ -456,8 +456,7 @@ func (a *API) ResetDataForm() { } } -// GetCourse retrieves the course information. -// This course does not retrieve directly from the API, but rather uses scraped data stored in Redis. +// GetCourse retrieves course information from the Redis cache. func (a *API) GetCourse(crn string) (*models.Course, error) { // Create a timeout context for Redis operations ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) diff --git a/internal/api/scrape.go b/internal/api/scrape.go index 0f3a038..78b01dc 100644 --- a/internal/api/scrape.go +++ b/internal/api/scrape.go @@ -1,3 +1,4 @@ +// Package api provides the core functionality for interacting with the Banner API. package api import ( @@ -17,16 +18,19 @@ const ( ) var ( - // PriorityMajors is a list of majors that are considered to be high priority for scraping. This list is used to determine which majors to scrape first/most often. + // PriorityMajors is a list of majors that are considered to be high priority for scraping. + // This list is used to determine which majors to scrape first/most often. PriorityMajors = []string{"CS", "CPE", "MAT", "EE", "IS"} - // AncillaryMajors is a list of majors that are considered to be low priority for scraping. This list will not contain any majors that are in PriorityMajors. + // AncillaryMajors is a list of majors that are considered to be low priority for scraping. + // This list will not contain any majors that are in PriorityMajors. AncillaryMajors []string // AllMajors is a list of all majors that are available in the Banner system. AllMajors []string ) -// Scrape scrapes the API for all courses and stores them in Redis. +// Scrape retrieves all courses from the Banner API and stores them in Redis. // This is a long-running process that should be run in a goroutine. +// // TODO: Switch from hardcoded term to dynamic term func (a *API) Scrape() error { // For each subject, retrieve all courses @@ -68,7 +72,8 @@ func (a *API) Scrape() error { return nil } -// GetExpiredSubjects returns a list of subjects that are expired and should be scraped. +// GetExpiredSubjects returns a list of subjects that have expired and should be scraped again. +// It checks Redis for the "scraped" status of each major for the current term. func (a *API) GetExpiredSubjects() ([]string, error) { term := Default(time.Now()).ToString() subjects := make([]string, 0) @@ -100,8 +105,8 @@ func (a *API) GetExpiredSubjects() ([]string, error) { return subjects, nil } -// ScrapeMajor is the scraping invocation for a specific major. -// This function does not check whether scraping is required at this time, it is assumed that the caller has already done so. +// ScrapeMajor scrapes all courses for a specific major. +// This function does not check whether scraping is required at this time; it is assumed that the caller has already done so. func (a *API) ScrapeMajor(subject string) error { offset := 0 totalClassCount := 0 @@ -180,9 +185,7 @@ func (a *API) ScrapeMajor(subject string) error { } // CalculateExpiry calculates the expiry time until the next scrape for a major. -// term is the term for which the relevant course is occurring within. -// count is the number of courses that were scraped. -// priority is a boolean indicating whether the major is a priority major. +// The duration is based on the number of courses, whether the major is a priority, and if the term is archived. func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration { // An hour for every 100 classes baseExpiry := time.Hour * time.Duration(count/100) @@ -222,7 +225,7 @@ func (a *API) CalculateExpiry(term string, count int, priority bool) time.Durati } // IntakeCourse stores a course in Redis. -// This function is mostly a stub for now, but will be used to handle change identification, notifications, and SQLite upserts in the future. +// This function will be used to handle change identification, notifications, and SQLite upserts in the future. func (a *API) IntakeCourse(course models.Course) error { // Create a timeout context for Redis operations ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) diff --git a/internal/api/search.go b/internal/api/search.go index 75f177e..e75c882 100644 --- a/internal/api/search.go +++ b/internal/api/search.go @@ -33,7 +33,7 @@ const ( ) // Query represents a search query for courses. -// It is a builder struct that allows for chaining of methods to build a query. +// It is a builder that allows for chaining methods to construct a query. type Query struct { subject *string title *string @@ -58,25 +58,25 @@ func NewQuery() *Query { return &Query{maxResults: 8, offset: 0} } -// Subject sets the subject for the query +// Subject sets the subject for the query. func (q *Query) Subject(subject string) *Query { q.subject = &subject return q } -// Title sets the title for the query +// Title sets the title for the query. func (q *Query) Title(title string) *Query { q.title = &title return q } -// Keywords sets the keywords for the query +// Keywords sets the keywords for the query. func (q *Query) Keywords(keywords []string) *Query { q.keywords = &keywords return q } -// Keyword adds a keyword to the query +// Keyword adds a keyword to the query. func (q *Query) Keyword(keyword string) *Query { if q.keywords == nil { q.keywords = &[]string{keyword} @@ -86,86 +86,86 @@ func (q *Query) Keyword(keyword string) *Query { return q } -// OpenOnly sets the open only flag for the query +// OpenOnly sets whether to search for open courses only. func (q *Query) OpenOnly(openOnly bool) *Query { q.openOnly = &openOnly return q } -// TermPart sets the term part for the query +// TermPart sets the term part for the query. func (q *Query) TermPart(termPart []string) *Query { q.termPart = &termPart return q } -// Campus sets the campuses for the query +// Campus sets the campuses for the query. func (q *Query) Campus(campus []string) *Query { q.campus = &campus return q } -// InstructionalMethod sets the instructional methods for the query +// InstructionalMethod sets the instructional methods for the query. func (q *Query) InstructionalMethod(instructionalMethod []string) *Query { q.instructionalMethod = &instructionalMethod return q } -// Attributes sets the attributes for the query +// Attributes sets the attributes for the query. func (q *Query) Attributes(attributes []string) *Query { q.attributes = &attributes return q } -// Instructor sets the instructors for the query +// Instructor sets the instructors for the query. func (q *Query) Instructor(instructor []uint64) *Query { q.instructor = &instructor return q } -// StartTime sets the start time for the query +// StartTime sets the start time for the query. func (q *Query) StartTime(startTime time.Duration) *Query { q.startTime = &startTime return q } -// EndTime sets the end time for the query +// EndTime sets the end time for the query. func (q *Query) EndTime(endTime time.Duration) *Query { q.endTime = &endTime return q } -// Credits sets the credit range for the query +// Credits sets the credit range for the query. func (q *Query) Credits(low int, high int) *Query { q.minCredits = &low q.maxCredits = &high return q } -// MinCredits sets the minimum credits for the query +// MinCredits sets the minimum credits for the query. func (q *Query) MinCredits(value int) *Query { q.minCredits = &value return q } -// MaxCredits sets the maximum credits for the query +// MaxCredits sets the maximum credits for the query. func (q *Query) MaxCredits(value int) *Query { q.maxCredits = &value return q } -// CourseNumbers sets the course number range for the query +// CourseNumbers sets the course number range for the query. func (q *Query) CourseNumbers(low int, high int) *Query { q.courseNumberRange = &Range{low, high} return q } -// Offset sets the offset for the query, allowing for pagination +// Offset sets the offset for pagination. func (q *Query) Offset(offset int) *Query { q.offset = offset return q } -// MaxResults sets the maximum number of results for the query +// MaxResults sets the maximum number of results to return. func (q *Query) MaxResults(maxResults int) *Query { q.maxResults = maxResults return q @@ -177,8 +177,8 @@ type Range struct { High int } -// FormatTimeParameter formats a time.Duration into a tuple of strings -// This is mostly a private helper to keep the parameter formatting for both the start and end time consistent together +// FormatTimeParameter formats a time.Duration into a tuple of strings for use in a POST request. +// It returns the hour, minute, and meridiem (AM/PM) as separate strings. func FormatTimeParameter(d time.Duration) (string, string, string) { hourParameter, minuteParameter, meridiemParameter := "", "", "" @@ -204,7 +204,7 @@ func FormatTimeParameter(d time.Duration) (string, string, string) { return hourParameter, minuteParameter, meridiemParameter } -// Paramify converts a Query into a map of parameters that can be used in a POST request +// Paramify converts a Query into a map of parameters for a POST request. // This function assumes each query key only appears once. func (q *Query) Paramify() map[string]string { params := map[string]string{} diff --git a/internal/api/session.go b/internal/api/session.go index 4ce8e6e..bf00cf9 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -7,6 +7,7 @@ import ( log "github.com/rs/zerolog/log" ) +// Setup makes the initial requests to set up the session cookies for the application. func (a *API) Setup() { // Makes the initial requests that sets up the session cookies for the rest of the application log.Info().Msg("Setting up session...") diff --git a/internal/api/term.go b/internal/api/term.go index ab863bb..174d460 100644 --- a/internal/api/term.go +++ b/internal/api/term.go @@ -18,6 +18,7 @@ const ( Fall ) +// Term represents a school term, consisting of a year and a season. type Term struct { Year uint16 Season uint8 @@ -32,16 +33,14 @@ func init() { SpringRange, SummerRange, FallRange = GetYearDayRange(loc, uint16(time.Now().Year())) } +// YearDayRange represents the start and end day of a term within a year. type YearDayRange struct { Start uint16 End uint16 } // GetYearDayRange returns the start and end day of each term for the given year. -// This could technically introduce race conditions, but it's more likely confusion from UTC will be a greater issue. -// Spring: January 14th to May -// Summer: May 25th - August 15th -// Fall: August 18th - December 10th +// The ranges are inclusive of the start day and exclusive of the end day. func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRange, YearDayRange) { springStart := time.Date(int(year), time.January, 14, 0, 0, 0, 0, loc).YearDay() springEnd := time.Date(int(year), time.May, 1, 0, 0, 0, 0, loc).YearDay() @@ -62,21 +61,9 @@ func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRang } } -// GetCurrentTerm returns the current term, and the next term. Only the first term is nillable. -// YearDay ranges are inclusive of the start, and exclusive of the end. -// You can think of the 'year' part of it as the 'school year', the second part of the 20XX-(20XX+1) phrasing. -// -// e.g. the Fall 2025, Spring 2026, and Summer 2026 terms all occur as part of the 2025-2026 school year. The second year, 2026, is the part used in all term identifiers. -// So even though the Fall 2025 term occurs in 2025, it uses the 2026 year in it's term identifier. -// -// Fall of 2024 => 202510 -// Spring of 2025 => 202520 -// Summer of 2025 => 202530 -// Fall of 2025 => 202610 -// Spring of 2026 => 202620 -// Summer of 2026 => 202630 -// -// Reading out 'Fall of 2024' as '202510' might be confusing, but it's correct. +// GetCurrentTerm returns the current and next terms based on the provided time. +// The current term can be nil if the time falls between terms. +// The 'year' in the term corresponds to the academic year, which may differ from the calendar year. func GetCurrentTerm(now time.Time) (*Term, *Term) { literalYear := uint16(now.Year()) dayOfYear := uint16(now.YearDay()) @@ -112,7 +99,7 @@ func GetCurrentTerm(now time.Time) (*Term, *Term) { panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear)) } -// ParseTerm converts a Banner term code to a Term struct +// ParseTerm converts a Banner term code string to a Term struct. func ParseTerm(code string) Term { year, _ := strconv.ParseUint(code[0:4], 10, 16) @@ -133,7 +120,7 @@ func ParseTerm(code string) Term { } } -// TermToBannerTerm converts a Term struct to a Banner term code +// ToString converts a Term struct to a Banner term code string. func (term Term) ToString() string { var season string switch term.Season { @@ -148,7 +135,7 @@ func (term Term) ToString() string { return fmt.Sprintf("%d%s", term.Year, season) } -// Default chooses the default term, which is the current term if it exists, otherwise the next term. +// Default returns the default term, which is the current term if it exists, otherwise the next term. func Default(t time.Time) Term { currentTerm, nextTerm := GetCurrentTerm(t) if currentTerm == nil { diff --git a/internal/bot/commands.go b/internal/bot/commands.go index 8353967..5b40d3a 100644 --- a/internal/bot/commands.go +++ b/internal/bot/commands.go @@ -22,11 +22,14 @@ const ( ICalTimestampFormatLocal = "20060102T150405" ) +// CommandHandler is a function that handles a slash command interaction. type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error var ( + // CommandDefinitions is a list of all the bot's command definitions. CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition} - CommandHandlers = map[string]CommandHandler{ + // CommandHandlers is a map of command names to their handlers. + CommandHandlers = map[string]CommandHandler{ TimeCommandDefinition.Name: TimeCommandHandler, TermCommandDefinition.Name: TermCommandHandler, SearchCommandDefinition.Name: SearchCommandHandler, @@ -82,6 +85,7 @@ var SearchCommandDefinition = &discordgo.ApplicationCommand{ }, } +// SearchCommandHandler handles the /search command, which allows users to search for courses. func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { data := i.ApplicationCommandData() query := api.NewQuery().Credits(3, 6) @@ -283,6 +287,7 @@ var TermCommandDefinition = &discordgo.ApplicationCommand{ }, } +// TermCommandHandler handles the /terms command, which allows users to search for terms. func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { data := i.ApplicationCommandData() @@ -353,6 +358,7 @@ var TimeCommandDefinition = &discordgo.ApplicationCommand{ }, } +// TimeCommandHandler handles the /time command, which allows users to get the meeting times for a course. func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { fetch_time := time.Now() crn := i.ApplicationCommandData().Options[0].IntValue() @@ -428,6 +434,7 @@ var IcsCommandDefinition = &discordgo.ApplicationCommand{ }, } +// IcsCommandHandler handles the /ics command, which allows users to generate an ICS file for a course. func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { // Parse all options options := internal.ParseOptions(i.ApplicationCommandData().Options) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index f6dae07..01df55b 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog/log" ) +// RegisterHandlers registers the bot's command handlers. func (b *Bot) RegisterHandlers() { b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) { // Handle commands during restart (highly unlikely, but just in case) diff --git a/internal/bot/state.go b/internal/bot/state.go index 4cb888f..27a309b 100644 --- a/internal/bot/state.go +++ b/internal/bot/state.go @@ -1,3 +1,4 @@ +// Package bot provides the core functionality for the Discord bot. package bot import ( @@ -10,6 +11,7 @@ import ( "github.com/rs/zerolog/log" ) +// Bot represents the state of the Discord bot. type Bot struct { Session *discordgo.Session API *api.API @@ -17,14 +19,17 @@ type Bot struct { isClosing bool } +// New creates a new Bot instance. func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot { return &Bot{Session: s, API: a, Config: c} } +// SetClosing marks the bot as closing, preventing new commands from being processed. func (b *Bot) SetClosing() { b.isClosing = true } +// GetSession ensures a valid session is available and selects the default term. func (b *Bot) GetSession() (string, error) { sessionID := b.API.EnsureSession() term := api.Default(time.Now()).ToString() diff --git a/internal/config/config.go b/internal/config/config.go index 291d448..91f03f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,14 +8,23 @@ import ( "resty.dev/v3" ) +// Config holds the application's configuration. type Config struct { - Ctx context.Context - CancelFunc context.CancelFunc - KV *redis.Client - Client *resty.Client - IsDevelopment bool - BaseURL string - Environment string + // Ctx is the application's root context. + Ctx context.Context + // CancelFunc cancels the application's root context. + CancelFunc context.CancelFunc + // KV provides access to the Redis cache. + KV *redis.Client + // Client is the HTTP client for making API requests. + Client *resty.Client + // IsDevelopment is true if the application is running in a development environment. + IsDevelopment bool + // BaseURL is the base URL for the Banner API. + BaseURL string + // Environment is the application's running environment (e.g. "development"). + Environment string + // CentralTimeLocation is the time.Location for US Central Time. CentralTimeLocation *time.Location } @@ -23,10 +32,11 @@ const ( CentralTimezoneName = "America/Chicago" ) +// New creates a new Config instance with a cancellable context. func New() (*Config, error) { ctx, cancel := context.WithCancel(context.Background()) - loc, err := time.LoadLocation(CentralTimezoneName) + loc, err := time.LoadLocation("America/Chicago") if err != nil { cancel() return nil, err @@ -39,23 +49,23 @@ func New() (*Config, error) { }, nil } -// SetBaseURL sets the base URL for API requests +// SetBaseURL sets the base URL for the Banner API. func (c *Config) SetBaseURL(url string) { c.BaseURL = url } -// SetEnvironment sets the environment +// SetEnvironment sets the application's environment. func (c *Config) SetEnvironment(env string) { c.Environment = env c.IsDevelopment = env == "development" } -// SetClient sets the Resty client +// SetClient sets the Resty client for making HTTP requests. func (c *Config) SetClient(client *resty.Client) { c.Client = client } -// SetRedis sets the Redis client +// SetRedis sets the Redis client for caching. func (c *Config) SetRedis(r *redis.Client) { c.KV = r } diff --git a/internal/config/logging.go b/internal/config/logging.go index 4440bed..09e0fab 100644 --- a/internal/config/logging.go +++ b/internal/config/logging.go @@ -1,3 +1,4 @@ +// Package config provides the configuration and logging setup for the application. package config import ( @@ -9,12 +10,7 @@ import ( const timeFormat = "2006-01-02 15:04:05" -var ( - stdConsole = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: timeFormat} - errConsole = zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: timeFormat} -) - -// NewConsoleWriter creates a new console writer with improved formatting for development +// NewConsoleWriter creates a new console writer that splits logs between stdout and stderr. func NewConsoleWriter() zerolog.LevelWriter { return &ConsoleLogSplitter{ stdConsole: zerolog.ConsoleWriter{ @@ -36,18 +32,18 @@ func NewConsoleWriter() zerolog.LevelWriter { } } -// ConsoleLogSplitter implements zerolog.LevelWriter with console formatting +// ConsoleLogSplitter is a zerolog.LevelWriter that writes to stdout for info/debug logs and stderr for warn/error logs, with console-friendly formatting. type ConsoleLogSplitter struct { stdConsole zerolog.ConsoleWriter errConsole zerolog.ConsoleWriter } -// Write should not be called +// Write is a passthrough to the standard console writer and should not be called directly. func (c *ConsoleLogSplitter) Write(p []byte) (n int, err error) { return c.stdConsole.Write(p) } -// WriteLevel write to the appropriate output with console formatting +// WriteLevel writes to the appropriate output (stdout or stderr) with console formatting based on the log level. func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { if level <= zerolog.WarnLevel { return c.stdConsole.Write(p) @@ -55,22 +51,21 @@ func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, e return c.errConsole.Write(p) } -// LogSplitter implements zerolog.LevelWriter +// LogSplitter is a zerolog.LevelWriter that writes to stdout for info/debug logs and stderr for warn/error logs. type LogSplitter struct { Std io.Writer Err io.Writer } -// Write should not be called +// Write is a passthrough to the standard writer and should not be called directly. func (l LogSplitter) Write(p []byte) (n int, err error) { return l.Std.Write(p) } -// WriteLevel write to the appropriate output +// WriteLevel writes to the appropriate output (stdout or stderr) based on the log level. func (l LogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { if level <= zerolog.WarnLevel { return l.Std.Write(p) - } else { - return l.Err.Write(p) } + return l.Err.Write(p) } diff --git a/internal/errors.go b/internal/errors.go index 3991c92..87c0b21 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -2,6 +2,7 @@ package internal import "fmt" +// UnexpectedContentTypeError is returned when the Content-Type header of a response does not match the expected value. type UnexpectedContentTypeError struct { Expected string Actual string diff --git a/internal/helpers.go b/internal/helpers.go index 45dac8e..6c4d67e 100644 --- a/internal/helpers.go +++ b/internal/helpers.go @@ -21,10 +21,10 @@ import ( "banner/internal/config" ) -// Options is a map of options from a discord command. +// Options is a map of options from a Discord command. type Options map[string]*discordgo.ApplicationCommandInteractionDataOption -// GetInt returns the integer value of an option. +// GetInt returns the integer value of an option, or 0 if it doesn't exist. func (o Options) GetInt(key string) int64 { if opt, ok := o[key]; ok { return opt.IntValue() @@ -32,7 +32,7 @@ func (o Options) GetInt(key string) int64 { return 0 } -// ParseOptions parses slash command options into a map. +// ParseOptions parses slash command options into a map for easier access. func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options { optionMap := make(Options) for _, opt := range options { @@ -41,12 +41,12 @@ func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) return optionMap } -// AddUserAgent adds a false but consistent user agent to the request +// AddUserAgent adds a consistent user agent to the request to mimic a real browser. func AddUserAgent(req *http.Request) { req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") } -// ContentTypeMatch checks if the Resty response has the given content type +// ContentTypeMatch checks if a Resty response has the given content type. func ContentTypeMatch(res *resty.Response, expectedContentType string) bool { contentType := res.Header().Get("Content-Type") if contentType == "" { @@ -57,8 +57,8 @@ func ContentTypeMatch(res *resty.Response, expectedContentType string) bool { const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" -// RandomString returns a random string of length n using the letterBytes constant -// The constant used is specifically chosen to mimic Ellucian's banner session ID generation. +// RandomString returns a random string of length n. +// The character set is chosen to mimic Ellucian's Banner session ID generation. func RandomString(n int) string { b := make([]byte, n) for i := range b { @@ -67,8 +67,7 @@ func RandomString(n int) string { return string(b) } -// DiscordGoLogger is a specialized helper function that implements discordgo's global logging interface. -// It directs all logs to the zerolog implementation. +// DiscordGoLogger is a helper function that implements discordgo's logging interface, directing all logs to zerolog. func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) { pc, file, line, _ := runtime.Caller(caller) @@ -98,13 +97,13 @@ func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) { event.Str("file", file).Int("line", line).Str("function", name).Msg(msg) } -// Nonce returns a string made up of the current time in milliseconds, Unix epoch/UTC -// This is typically used as a query parameter to prevent request caching in the browser. +// Nonce returns the current time in milliseconds since the Unix epoch as a string. +// This is typically used as a query parameter to prevent request caching. func Nonce() string { return strconv.Itoa(int(time.Now().UnixMilli())) } -// Plural is a simple helper function that returns an empty string if n is 1, and "s" otherwise. +// Plural returns "s" if n is not 1. func Plural(n int) string { if n == 1 { return "" @@ -112,8 +111,7 @@ func Plural(n int) string { return "s" } -// Plurale is a simple helper function that returns an empty string if n is 1, and "ess" otherwise. -// This is for words that end in "es" when plural. +// Plurale returns "es" if n is not 1. func Plurale(n int) string { if n == 1 { return "" @@ -121,6 +119,7 @@ func Plurale(n int) string { return "es" } +// WeekdaysToString converts a map of weekdays to a compact string representation (e.g., "MWF"). func WeekdaysToString(days map[time.Weekday]bool) string { // If no days are present numDays := len(days) @@ -166,15 +165,18 @@ func WeekdaysToString(days map[time.Weekday]bool) string { return str } +// NaiveTime represents a time of day without a date or timezone. type NaiveTime struct { Hours uint Minutes uint } +// Sub returns the duration between two NaiveTime instances. func (nt *NaiveTime) Sub(other *NaiveTime) time.Duration { return time.Hour*time.Duration(nt.Hours-other.Hours) + time.Minute*time.Duration(nt.Minutes-other.Minutes) } +// ParseNaiveTime converts an integer representation of time (e.g., 1430) to a NaiveTime struct. func ParseNaiveTime(integer uint64) *NaiveTime { minutes := uint(integer % 100) hours := uint(integer / 100) @@ -182,6 +184,7 @@ func ParseNaiveTime(integer uint64) *NaiveTime { return &NaiveTime{Hours: hours, Minutes: minutes} } +// String returns a string representation of the NaiveTime in 12-hour format (e.g., "2:30PM"). func (nt NaiveTime) String() string { meridiem := "AM" hour := nt.Hours @@ -194,6 +197,7 @@ func (nt NaiveTime) String() string { return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem) } +// GetFirstEnv returns the value of the first environment variable that is set. func GetFirstEnv(key ...string) string { for _, k := range key { if v := os.Getenv(k); v != "" { @@ -203,14 +207,12 @@ func GetFirstEnv(key ...string) string { return "" } -// GetIntPointer returns a pointer to the given value. -// This function is useful for discordgo, which inexplicably requires pointers to integers for minLength arguments. +// GetIntPointer returns a pointer to the given integer. func GetIntPointer(value int) *int { return &value } -// GetFloatPointer returns a pointer to the given value. -// This function is useful for discordgo, which inexplicably requires pointers to floats for minLength arguments. +// GetFloatPointer returns a pointer to the given float. func GetFloatPointer(value float64) *float64 { return &value } @@ -244,6 +246,7 @@ var extensionMap = map[string]string{ "image/jxl": "jxl", } +// GuessExtension guesses the file extension for a given content type. func GuessExtension(contentType string) string { ext, ok := extensionMap[strings.ToLower(contentType)] if !ok { @@ -252,7 +255,7 @@ func GuessExtension(contentType string) string { return ext } -// DumpResponse dumps a Resty response body to a file for debugging purposes +// DumpResponse dumps the body of a Resty response to a file for debugging. func DumpResponse(res *resty.Response) { contentType := res.Header().Get("Content-Type") ext := GuessExtension(contentType) @@ -282,8 +285,7 @@ func DumpResponse(res *resty.Response) { log.Info().Str("filename", filename).Str("content-type", contentType).Msg("Dumped response body") } -// ResponseError responds to an interaction with an error message -// TODO: Improve with a proper embed and colors +// RespondError responds to an interaction with a formatted error message. func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error { // Optional: log the error if err != nil { @@ -307,26 +309,25 @@ func RespondError(session *discordgo.Session, interaction *discordgo.Interaction }) } +// GetFetchedFooter returns a standard footer for embeds, indicating when the data was fetched. func GetFetchedFooter(cfg *config.Config, time time.Time) *discordgo.MessageEmbedFooter { return &discordgo.MessageEmbedFooter{ Text: fmt.Sprintf("Fetched at %s", time.In(cfg.CentralTimeLocation).Format("Monday, January 2, 2006 at 3:04:05PM")), } } -// GetUser returns the user from the interaction. -// This helper method is useful as depending on where the message was sent (guild or DM), the user is in a different field. +// GetUser returns the user from an interaction, regardless of whether it was in a guild or a DM. func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User { - // If the interaction is in a guild, the user is kept in the Member field + // If the interaction is in a guild, the user is in the Member field if interaction.Member != nil { return interaction.Member.User } - // If the interaction is in a DM, the user is kept in the User field + // If the interaction is in a DM, the user is in the User field return interaction.User } -// Encode encodes the values into “URL encoded” form -// ("bar=baz&foo=quux") sorted by key. +// EncodeParams encodes a map of parameters into a URL-encoded string, sorted by key. func EncodeParams(params map[string]*[]string) string { // Escape hatch for nil if params == nil { @@ -362,11 +363,12 @@ func EncodeParams(params map[string]*[]string) string { return buf.String() } -// Point represents a point in 2D space +// Point represents a point in 2D space. type Point struct { X, Y float64 } +// Slope calculates the y-coordinate of a point on a line given two other points and an x-coordinate. func Slope(p1 Point, p2 Point, x float64) Point { slope := (p2.Y - p1.Y) / (p2.X - p1.X) newY := slope*(x-p1.X) + p1.Y diff --git a/internal/meta.go b/internal/meta.go index 5959c2c..b7116d7 100644 --- a/internal/meta.go +++ b/internal/meta.go @@ -1,3 +1,4 @@ +// Package internal provides shared functionality for the banner application. package internal import ( @@ -10,7 +11,7 @@ import ( log "github.com/rs/zerolog/log" ) -// GetGuildName returns the name of the guild with the given ID, utilizing Redis to cache the value +// GetGuildName returns the name of a guild by its ID, using Redis for caching. func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string) string { // Create a timeout context for Redis operations ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second) @@ -52,7 +53,7 @@ func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string return guild.Name } -// GetChannelName returns the name of the channel with the given ID, utilizing Redis to cache the value +// GetChannelName returns the name of a channel by its ID, using Redis for caching. func GetChannelName(cfg *config.Config, session *discordgo.Session, channelID string) string { // Create a timeout context for Redis operations ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second) diff --git a/internal/models/types.go b/internal/models/types.go index 2dc542d..348abda 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -1,3 +1,4 @@ +// Package models provides the data structures for the Banner API. package models import ( @@ -11,8 +12,9 @@ import ( log "github.com/rs/zerolog/log" ) +// FacultyItem represents a faculty member associated with a course. type FacultyItem struct { - BannerId string `json:"bannerId"` + BannerID string `json:"bannerId"` Category *string `json:"category"` Class string `json:"class"` CourseReferenceNumber string `json:"courseReferenceNumber"` @@ -22,6 +24,7 @@ type FacultyItem struct { Term string `json:"term"` } +// MeetingTimeResponse represents the meeting time information for a course. type MeetingTimeResponse struct { Category *string `json:"category"` Class string `json:"class"` @@ -80,6 +83,7 @@ type MeetingTimeResponse struct { Term string `json:"term"` } +// String returns a formatted string representation of the meeting time. func (m *MeetingTimeResponse) String() string { switch m.MeetingTime.MeetingType { case "HB": @@ -104,6 +108,7 @@ func (m *MeetingTimeResponse) String() string { return "Unknown" } +// TimeString returns a formatted string of the meeting times (e.g., "MWF 1:00PM-2:15PM"). func (m *MeetingTimeResponse) TimeString() string { startTime := m.StartTime() endTime := m.EndTime() @@ -115,11 +120,11 @@ func (m *MeetingTimeResponse) TimeString() string { return fmt.Sprintf("%s %s-%s", internal.WeekdaysToString(m.Days()), m.StartTime().String(), m.EndTime().String()) } -// PlaceString returns a formatted string best representing the place of the meeting time +// PlaceString returns a formatted string representing the location of the meeting. func (m *MeetingTimeResponse) PlaceString() string { mt := m.MeetingTime - // TODO: ADd format case for partial online classes + // TODO: Add format case for partial online classes if mt.Room == "" { return "Online" } @@ -127,6 +132,7 @@ func (m *MeetingTimeResponse) PlaceString() string { return fmt.Sprintf("%s | %s | %s %s", mt.CampusDescription, mt.BuildingDescription, mt.Building, mt.Room) } +// Days returns a map of weekdays on which the course meets. func (m *MeetingTimeResponse) Days() map[time.Weekday]bool { days := map[time.Weekday]bool{} @@ -140,7 +146,7 @@ func (m *MeetingTimeResponse) Days() map[time.Weekday]bool { return days } -// Returns the BYDAY value for the iCalendar RRule format +// ByDay returns a comma-separated string of two-letter day abbreviations for the iCalendar RRule. func (m *MeetingTimeResponse) ByDay() string { days := []string{} @@ -171,8 +177,8 @@ func (m *MeetingTimeResponse) ByDay() string { const layout = "01/02/2006" -// StartDay returns the start date of the meeting time as a time.Time object -// This is not cached and is parsed on each invocation. It may also panic without handling. +// StartDay returns the start date of the meeting as a time.Time object. +// This method is not cached and will panic if the date cannot be parsed. func (m *MeetingTimeResponse) StartDay() time.Time { t, err := time.Parse(layout, m.MeetingTime.StartDate) if err != nil { @@ -181,8 +187,8 @@ func (m *MeetingTimeResponse) StartDay() time.Time { return t } -// EndDay returns the end date of the meeting time as a time.Time object. -// This is not cached and is parsed on each invocation. It may also panic without handling. +// EndDay returns the end date of the meeting as a time.Time object. +// This method is not cached and will panic if the date cannot be parsed. func (m *MeetingTimeResponse) EndDay() time.Time { t, err := time.Parse(layout, m.MeetingTime.EndDate) if err != nil { @@ -191,8 +197,8 @@ func (m *MeetingTimeResponse) EndDay() time.Time { return t } -// StartTime returns the start time of the meeting time as a NaiveTime object -// This is not cached and is parsed on each invocation. It may also panic without handling. +// StartTime returns the start time of the meeting as a NaiveTime object. +// This method is not cached and will panic if the time cannot be parsed. func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime { raw := m.MeetingTime.BeginTime if raw == "" { @@ -207,8 +213,8 @@ func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime { return internal.ParseNaiveTime(value) } -// EndTime returns the end time of the meeting time as a NaiveTime object -// This is not cached and is parsed on each invocation. It may also panic without handling. +// EndTime returns the end time of the meeting as a NaiveTime object. +// This method is not cached and will panic if the time cannot be parsed. func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime { raw := m.MeetingTime.EndTime if raw == "" { @@ -223,12 +229,13 @@ func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime { return internal.ParseNaiveTime(value) } +// RRule represents a recurrence rule for an iCalendar event. type RRule struct { Until string ByDay string } -// Converts the meeting time to a string that satisfies the iCalendar RRule format +// RRule converts the meeting time to a struct that satisfies the iCalendar RRule format. func (m *MeetingTimeResponse) RRule() RRule { return RRule{ Until: m.EndDay().UTC().Format("20060102T150405Z"), @@ -236,6 +243,7 @@ func (m *MeetingTimeResponse) RRule() RRule { } } +// SearchResult represents the result of a course search. type SearchResult struct { Success bool `json:"success"` TotalCount int `json:"totalCount"` @@ -249,41 +257,36 @@ type SearchResult struct { Data []Course `json:"data"` } +// Course represents a single course returned from a search. type Course struct { - // A internal identifier not used outside of the Banner system - Id int `json:"id"` - // The internal identifier for the term this class is in (e.g. 202420) + // ID is an internal identifier not used outside of the Banner system. + ID int `json:"id"` + // Term is the internal identifier for the term this class is in (e.g. 202420). Term string `json:"term"` - // The human-readable name of the term this class is in (e.g. Fall 2021) + // TermDesc is the human-readable name of the term this class is in (e.g. Fall 2021). TermDesc string `json:"termDesc"` - // The specific identifier that describes this individual course. CRNs are unique to a term. (TODO: Verify this is true) + // CourseReferenceNumber is the unique identifier for a course within a term. CourseReferenceNumber string `json:"courseReferenceNumber"` - // A rarely used identifier that species which part of the given term this course is in. By default, this is "1" to encompass the entire term. (e.g. B6, B5) + // PartOfTerm specifies which part of the term the course is in (e.g. B6, B5). PartOfTerm string `json:"partOfTerm"` - // The 4-digit course code that defines this course (e.g. 3743, 0120, 4855, 7339), but not the specific instance (see CourseReferenceNumber) + // CourseNumber is the 4-digit code for the course (e.g. 3743). CourseNumber string `json:"courseNumber"` - // The short acronym of the course subject (e.g. CS, AEPI) + // Subject is the subject acronym (e.g. CS, AEPI). Subject string `json:"subject"` - // The full name of the course subject (e.g. Computer Science, Academic English Program-Intl.) + // SubjectDescription is the full name of the course subject. SubjectDescription string `json:"subjectDescription"` - // The specific section of the course (e.g. 001, 002) - SequenceNumber string `json:"sequenceNumber"` - // The long name of the campus this course takes place at (e.g. Main Campus, Downtown Campus) + // SequenceNumber is the course section (e.g. 001, 002). + SequenceNumber string `json:"sequenceNumber"` CampusDescription string `json:"campusDescription"` - // e.g. Lecture, Seminar, Dissertation, Internship, Independent Study, Thesis, Self-paced, Laboratory + // ScheduleTypeDescription is the type of schedule for the course (e.g. Lecture, Seminar). ScheduleTypeDescription string `json:"scheduleTypeDescription"` - // The long name of the course (generally) - CourseTitle string `json:"courseTitle"` - CreditHours int `json:"creditHours"` - // The maximum number of students that can enroll in this course - MaximumEnrollment int `json:"maximumEnrollment"` - // The number of students currently enrolled in this course - Enrollment int `json:"enrollment"` - // The number of seats available in this course (MaximumEnrollment - Enrollment) - SeatsAvailable int `json:"seatsAvailable"` - // The number of students that could waitlist for this course - WaitCapacity int `json:"waitCapacity"` - // The number of students currently on the waitlist for this course + CourseTitle string `json:"courseTitle"` + CreditHours int `json:"creditHours"` + // MaximumEnrollment is the maximum number of students that can enroll. + MaximumEnrollment int `json:"maximumEnrollment"` + Enrollment int `json:"enrollment"` + SeatsAvailable int `json:"seatsAvailable"` + WaitCapacity int `json:"waitCapacity"` WaitCount int `json:"waitCount"` CrossList *string `json:"crossList"` CrossListCapacity *int `json:"crossListCapacity"` @@ -295,27 +298,26 @@ type Course struct { OpenSection bool `json:"openSection"` LinkIdentifier *string `json:"linkIdentifier"` IsSectionLinked bool `json:"isSectionLinked"` - // A combination of the subject and course number (e.g. subject=CS, courseNumber=3443 => "CS3443") + // SubjectCourse is the combination of the subject and course number (e.g. CS3443). SubjectCourse string `json:"subjectCourse"` ReservedSeatSummary *string `json:"reservedSeatSummary"` InstructionalMethod string `json:"instructionalMethod"` InstructionalMethodDescription string `json:"instructionalMethodDescription"` SectionAttributes []struct { - // A internal API class identifier used by Banner + // Class is an internal API class identifier used by Banner. Class string `json:"class"` CourseReferenceNumber string `json:"courseReferenceNumber"` - // UPPR, ZIEP, AIS, LEWR, ZZSL, 090, GRAD, ZZTL, 020, BU, CLEP - Code string `json:"code"` - // Seems to be the fully qualified meaning of the Code (Upper, Intensive English Program...) - Description string `json:"description"` - TermCode string `json:"termCode"` - // Unknown; always false - IsZtcAttribute bool `json:"isZTCAttribute"` + // Code for the attribute (e.g., UPPR, ZIEP, AIS). + Code string `json:"code"` + Description string `json:"description"` + TermCode string `json:"termCode"` + IsZtcAttribute bool `json:"isZTCAttribute"` } `json:"sectionAttributes"` Faculty []FacultyItem `json:"faculty"` MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"` } +// MarshalBinary implements the encoding.BinaryMarshaler interface. func (course Course) MarshalBinary() ([]byte, error) { return json.Marshal(course) }