mirror of
https://github.com/Xevion/banner.git
synced 2025-12-08 10:06:28 -06:00
docs: add trivial documentation for all types, functions, packages etc.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,10 +22,13 @@ 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 is a map of command names to their handlers.
|
||||
CommandHandlers = map[string]CommandHandler{
|
||||
TimeCommandDefinition.Name: TimeCommandHandler,
|
||||
TermCommandDefinition.Name: TermCommandHandler,
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,14 +8,23 @@ import (
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// Config holds the application's configuration.
|
||||
type Config struct {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 is the course section (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)
|
||||
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 is the maximum number of students that can enroll.
|
||||
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
|
||||
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 for the attribute (e.g., UPPR, ZIEP, AIS).
|
||||
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"`
|
||||
} `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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user