mirror of
https://github.com/Xevion/banner.git
synced 2025-12-08 18:06:42 -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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -73,6 +74,7 @@ func init() {
|
|||||||
discordgo.Logger = internal.DiscordGoLogger
|
discordgo.Logger = internal.DiscordGoLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initRedis initializes the Redis client and pings the server to ensure a connection.
|
||||||
func initRedis(cfg *config.Config) {
|
func initRedis(cfg *config.Config) {
|
||||||
// Setup redis
|
// Setup redis
|
||||||
redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")
|
redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL")
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import (
|
|||||||
"resty.dev/v3"
|
"resty.dev/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// API provides a client for interacting with the Banner API.
|
||||||
type API struct {
|
type API struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New creates a new API client with the given configuration.
|
||||||
func New(config *config.Config) *API {
|
func New(config *config.Config) *API {
|
||||||
return &API{config: config}
|
return &API{config: config}
|
||||||
}
|
}
|
||||||
@@ -34,7 +36,7 @@ var (
|
|||||||
expiryTime = 25 * time.Minute
|
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 {
|
func SessionMiddleware(c *resty.Client, r *resty.Response) error {
|
||||||
// log.Debug().Str("url", r.Request.RawRequest.URL.Path).Msg("Session middleware")
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSession generates a new session ID (nonce) for use with the Banner API.
|
// GenerateSession generates a new session ID for use with the Banner API.
|
||||||
// Don't use this function directly, use GetSession instead.
|
// This function should not be used directly; use EnsureSession instead.
|
||||||
func GenerateSession() string {
|
func GenerateSession() string {
|
||||||
return internal.RandomString(5) + internal.Nonce()
|
return internal.RandomString(5) + internal.Nonce()
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ func GenerateSession() string {
|
|||||||
var terms []BannerTerm
|
var terms []BannerTerm
|
||||||
var lastTermUpdate time.Time
|
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 {
|
func (a *API) TryReloadTerms() error {
|
||||||
if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour {
|
if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour {
|
||||||
return nil
|
return nil
|
||||||
@@ -74,8 +76,9 @@ func (a *API) TryReloadTerms() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTermArchived checks if the given term is archived
|
// IsTermArchived checks if the given term is archived (view only).
|
||||||
// TODO: Add error, switch missing term logic to error
|
//
|
||||||
|
// TODO: Add error handling for when a term does not exist.
|
||||||
func (a *API) IsTermArchived(term string) bool {
|
func (a *API) IsTermArchived(term string) bool {
|
||||||
// Ensure the terms are loaded
|
// Ensure the terms are loaded
|
||||||
err := a.TryReloadTerms()
|
err := a.TryReloadTerms()
|
||||||
@@ -106,21 +109,25 @@ func (a *API) EnsureSession() string {
|
|||||||
return latestSession
|
return latestSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pair represents a key-value pair from the Banner API.
|
||||||
type Pair struct {
|
type Pair struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BannerTerm represents a term in the Banner system.
|
||||||
type BannerTerm Pair
|
type BannerTerm Pair
|
||||||
|
|
||||||
|
// Instructor represents an instructor in the Banner system.
|
||||||
type Instructor Pair
|
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 {
|
func (term BannerTerm) Archived() bool {
|
||||||
return strings.Contains(term.Description, "View Only")
|
return strings.Contains(term.Description, "View Only")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTerms retrieves and parses the term information for a given search term.
|
// GetTerms retrieves a list of terms from the Banner API.
|
||||||
// Page number must be at least 1.
|
// The page number must be at least 1.
|
||||||
func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) {
|
func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) {
|
||||||
// Ensure offset is valid
|
// Ensure offset is valid
|
||||||
if page <= 0 {
|
if page <= 0 {
|
||||||
@@ -148,8 +155,8 @@ func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, e
|
|||||||
return *terms, nil
|
return *terms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectTerm selects the given term in the Banner system.
|
// SelectTerm selects a term in the Banner system for the given session.
|
||||||
// This function completes the initial term selection process, which is required before any other API calls can be made with the session ID.
|
// This is required before other API calls can be made.
|
||||||
func (a *API) SelectTerm(term string, sessionID string) error {
|
func (a *API) SelectTerm(term string, sessionID string) error {
|
||||||
form := url.Values{
|
form := url.Values{
|
||||||
"term": {term},
|
"term": {term},
|
||||||
@@ -192,8 +199,8 @@ func (a *API) SelectTerm(term string, sessionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPartOfTerms retrieves and parses the part of term information for a given term.
|
// GetPartOfTerms retrieves a list of parts of a term from the Banner API.
|
||||||
// Ensure that the offset is greater than 0.
|
// The page number must be at least 1.
|
||||||
func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) {
|
func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) {
|
||||||
// Ensure offset is valid
|
// Ensure offset is valid
|
||||||
if offset <= 0 {
|
if offset <= 0 {
|
||||||
@@ -223,10 +230,7 @@ func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int
|
|||||||
return *terms, nil
|
return *terms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstructors retrieves and parses the instructor information for a given search term.
|
// GetInstructors retrieves a list of instructors from the Banner API.
|
||||||
// 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.
|
|
||||||
func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) {
|
func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) {
|
||||||
// Ensure offset is valid
|
// Ensure offset is valid
|
||||||
if offset <= 0 {
|
if offset <= 0 {
|
||||||
@@ -256,11 +260,13 @@ func (a *API) GetInstructors(search string, term string, offset int, maxResults
|
|||||||
return *instructors, nil
|
return *instructors, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClassDetails represents the details of a course.
|
// ClassDetails represents the detailed information for a class.
|
||||||
// TODO: Finish this struct & function
|
//
|
||||||
|
// TODO: Implement this struct and the associated GetCourseDetails function.
|
||||||
type ClassDetails struct {
|
type ClassDetails struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCourseDetails retrieves the details for a specific course.
|
||||||
func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
|
func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
|
||||||
body, err := json.Marshal(map[string]string{
|
body, err := json.Marshal(map[string]string{
|
||||||
"term": strconv.Itoa(term),
|
"term": strconv.Itoa(term),
|
||||||
@@ -289,7 +295,7 @@ func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) {
|
|||||||
return details, nil
|
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) {
|
func (a *API) Search(term string, query *Query, sort string, sortDescending bool) (*models.SearchResult, error) {
|
||||||
a.ResetDataForm()
|
a.ResetDataForm()
|
||||||
|
|
||||||
@@ -322,9 +328,8 @@ func (a *API) Search(term string, query *Query, sort string, sortDescending bool
|
|||||||
return searchResult, nil
|
return searchResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSubjects retrieves and parses the subject information for a given search term.
|
// GetSubjects retrieves a list of subjects from the Banner API.
|
||||||
// The results of this response shouldn't change much, but technically could as new majors are developed, or old ones are removed.
|
// The page number must be at least 1.
|
||||||
// Ensure that the offset is greater than 0.
|
|
||||||
func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) {
|
func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) {
|
||||||
// Ensure offset is valid
|
// Ensure offset is valid
|
||||||
if offset <= 0 {
|
if offset <= 0 {
|
||||||
@@ -354,10 +359,8 @@ func (a *API) GetSubjects(search string, term string, offset int, maxResults int
|
|||||||
return *subjects, nil
|
return *subjects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCampuses retrieves and parses the campus information for a given search term.
|
// GetCampuses retrieves a list of campuses from the Banner API.
|
||||||
// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term.
|
// The page number must be at least 1.
|
||||||
// This function is included for completeness, but probably isn't useful.
|
|
||||||
// Ensure that the offset is greater than 0.
|
|
||||||
func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) {
|
func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) {
|
||||||
// Ensure offset is valid
|
// Ensure offset is valid
|
||||||
if offset <= 0 {
|
if offset <= 0 {
|
||||||
@@ -387,10 +390,8 @@ func (a *API) GetCampuses(search string, term int, offset int, maxResults int) (
|
|||||||
return *campuses, nil
|
return *campuses, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstructionalMethods retrieves and parses the instructional method information for a given search term.
|
// GetInstructionalMethods retrieves a list of instructional methods from the Banner API.
|
||||||
// In my opinion, it is unclear what providing the term does, as the results should be the same regardless of the term.
|
// The page number must be at least 1.
|
||||||
// This function is included for completeness, but probably isn't useful.
|
|
||||||
// Ensure that the offset is greater than 0.
|
|
||||||
func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) {
|
func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) {
|
||||||
// Ensure offset is valid
|
// Ensure offset is valid
|
||||||
if offset <= 0 {
|
if offset <= 0 {
|
||||||
@@ -419,9 +420,7 @@ func (a *API) GetInstructionalMethods(search string, term string, offset int, ma
|
|||||||
return *methods, nil
|
return *methods, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCourseMeetingTime retrieves the meeting time information for a course based on the given term and course reference number (CRN).
|
// GetCourseMeetingTime retrieves the meeting time information for a course.
|
||||||
// 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.
|
|
||||||
func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) {
|
func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) {
|
||||||
type responseWrapper struct {
|
type responseWrapper struct {
|
||||||
Fmt []models.MeetingTimeResponse `json:"fmt"`
|
Fmt []models.MeetingTimeResponse `json:"fmt"`
|
||||||
@@ -446,7 +445,8 @@ func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeRespo
|
|||||||
return result.Fmt, nil
|
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() {
|
func (a *API) ResetDataForm() {
|
||||||
req := a.config.Client.NewRequest()
|
req := a.config.Client.NewRequest()
|
||||||
|
|
||||||
@@ -456,8 +456,7 @@ func (a *API) ResetDataForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCourse retrieves the course information.
|
// GetCourse retrieves course information from the Redis cache.
|
||||||
// This course does not retrieve directly from the API, but rather uses scraped data stored in Redis.
|
|
||||||
func (a *API) GetCourse(crn string) (*models.Course, error) {
|
func (a *API) GetCourse(crn string) (*models.Course, error) {
|
||||||
// Create a timeout context for Redis operations
|
// Create a timeout context for Redis operations
|
||||||
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)
|
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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,16 +18,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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"}
|
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
|
AncillaryMajors []string
|
||||||
// AllMajors is a list of all majors that are available in the Banner system.
|
// AllMajors is a list of all majors that are available in the Banner system.
|
||||||
AllMajors []string
|
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.
|
// This is a long-running process that should be run in a goroutine.
|
||||||
|
//
|
||||||
// TODO: Switch from hardcoded term to dynamic term
|
// TODO: Switch from hardcoded term to dynamic term
|
||||||
func (a *API) Scrape() error {
|
func (a *API) Scrape() error {
|
||||||
// For each subject, retrieve all courses
|
// For each subject, retrieve all courses
|
||||||
@@ -68,7 +72,8 @@ func (a *API) Scrape() error {
|
|||||||
return nil
|
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) {
|
func (a *API) GetExpiredSubjects() ([]string, error) {
|
||||||
term := Default(time.Now()).ToString()
|
term := Default(time.Now()).ToString()
|
||||||
subjects := make([]string, 0)
|
subjects := make([]string, 0)
|
||||||
@@ -100,8 +105,8 @@ func (a *API) GetExpiredSubjects() ([]string, error) {
|
|||||||
return subjects, nil
|
return subjects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScrapeMajor is the scraping invocation for a specific major.
|
// 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.
|
// 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 {
|
func (a *API) ScrapeMajor(subject string) error {
|
||||||
offset := 0
|
offset := 0
|
||||||
totalClassCount := 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.
|
// CalculateExpiry calculates the expiry time until the next scrape for a major.
|
||||||
// term is the term for which the relevant course is occurring within.
|
// The duration is based on the number of courses, whether the major is a priority, and if the term is archived.
|
||||||
// count is the number of courses that were scraped.
|
|
||||||
// priority is a boolean indicating whether the major is a priority major.
|
|
||||||
func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration {
|
func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration {
|
||||||
// An hour for every 100 classes
|
// An hour for every 100 classes
|
||||||
baseExpiry := time.Hour * time.Duration(count/100)
|
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.
|
// 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 {
|
func (a *API) IntakeCourse(course models.Course) error {
|
||||||
// Create a timeout context for Redis operations
|
// Create a timeout context for Redis operations
|
||||||
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)
|
ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Query represents a search query for courses.
|
// 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 {
|
type Query struct {
|
||||||
subject *string
|
subject *string
|
||||||
title *string
|
title *string
|
||||||
@@ -58,25 +58,25 @@ func NewQuery() *Query {
|
|||||||
return &Query{maxResults: 8, offset: 0}
|
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 {
|
func (q *Query) Subject(subject string) *Query {
|
||||||
q.subject = &subject
|
q.subject = &subject
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title sets the title for the query
|
// Title sets the title for the query.
|
||||||
func (q *Query) Title(title string) *Query {
|
func (q *Query) Title(title string) *Query {
|
||||||
q.title = &title
|
q.title = &title
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keywords sets the keywords for the query
|
// Keywords sets the keywords for the query.
|
||||||
func (q *Query) Keywords(keywords []string) *Query {
|
func (q *Query) Keywords(keywords []string) *Query {
|
||||||
q.keywords = &keywords
|
q.keywords = &keywords
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyword adds a keyword to the query
|
// Keyword adds a keyword to the query.
|
||||||
func (q *Query) Keyword(keyword string) *Query {
|
func (q *Query) Keyword(keyword string) *Query {
|
||||||
if q.keywords == nil {
|
if q.keywords == nil {
|
||||||
q.keywords = &[]string{keyword}
|
q.keywords = &[]string{keyword}
|
||||||
@@ -86,86 +86,86 @@ func (q *Query) Keyword(keyword string) *Query {
|
|||||||
return q
|
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 {
|
func (q *Query) OpenOnly(openOnly bool) *Query {
|
||||||
q.openOnly = &openOnly
|
q.openOnly = &openOnly
|
||||||
return q
|
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 {
|
func (q *Query) TermPart(termPart []string) *Query {
|
||||||
q.termPart = &termPart
|
q.termPart = &termPart
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campus sets the campuses for the query
|
// Campus sets the campuses for the query.
|
||||||
func (q *Query) Campus(campus []string) *Query {
|
func (q *Query) Campus(campus []string) *Query {
|
||||||
q.campus = &campus
|
q.campus = &campus
|
||||||
return q
|
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 {
|
func (q *Query) InstructionalMethod(instructionalMethod []string) *Query {
|
||||||
q.instructionalMethod = &instructionalMethod
|
q.instructionalMethod = &instructionalMethod
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attributes sets the attributes for the query
|
// Attributes sets the attributes for the query.
|
||||||
func (q *Query) Attributes(attributes []string) *Query {
|
func (q *Query) Attributes(attributes []string) *Query {
|
||||||
q.attributes = &attributes
|
q.attributes = &attributes
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instructor sets the instructors for the query
|
// Instructor sets the instructors for the query.
|
||||||
func (q *Query) Instructor(instructor []uint64) *Query {
|
func (q *Query) Instructor(instructor []uint64) *Query {
|
||||||
q.instructor = &instructor
|
q.instructor = &instructor
|
||||||
return q
|
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 {
|
func (q *Query) StartTime(startTime time.Duration) *Query {
|
||||||
q.startTime = &startTime
|
q.startTime = &startTime
|
||||||
return q
|
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 {
|
func (q *Query) EndTime(endTime time.Duration) *Query {
|
||||||
q.endTime = &endTime
|
q.endTime = &endTime
|
||||||
return q
|
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 {
|
func (q *Query) Credits(low int, high int) *Query {
|
||||||
q.minCredits = &low
|
q.minCredits = &low
|
||||||
q.maxCredits = &high
|
q.maxCredits = &high
|
||||||
return q
|
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 {
|
func (q *Query) MinCredits(value int) *Query {
|
||||||
q.minCredits = &value
|
q.minCredits = &value
|
||||||
return q
|
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 {
|
func (q *Query) MaxCredits(value int) *Query {
|
||||||
q.maxCredits = &value
|
q.maxCredits = &value
|
||||||
return q
|
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 {
|
func (q *Query) CourseNumbers(low int, high int) *Query {
|
||||||
q.courseNumberRange = &Range{low, high}
|
q.courseNumberRange = &Range{low, high}
|
||||||
return q
|
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 {
|
func (q *Query) Offset(offset int) *Query {
|
||||||
q.offset = offset
|
q.offset = offset
|
||||||
return q
|
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 {
|
func (q *Query) MaxResults(maxResults int) *Query {
|
||||||
q.maxResults = maxResults
|
q.maxResults = maxResults
|
||||||
return q
|
return q
|
||||||
@@ -177,8 +177,8 @@ type Range struct {
|
|||||||
High int
|
High int
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatTimeParameter formats a time.Duration into a tuple of strings
|
// FormatTimeParameter formats a time.Duration into a tuple of strings for use in a POST request.
|
||||||
// This is mostly a private helper to keep the parameter formatting for both the start and end time consistent together
|
// It returns the hour, minute, and meridiem (AM/PM) as separate strings.
|
||||||
func FormatTimeParameter(d time.Duration) (string, string, string) {
|
func FormatTimeParameter(d time.Duration) (string, string, string) {
|
||||||
hourParameter, minuteParameter, meridiemParameter := "", "", ""
|
hourParameter, minuteParameter, meridiemParameter := "", "", ""
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ func FormatTimeParameter(d time.Duration) (string, string, string) {
|
|||||||
return hourParameter, minuteParameter, meridiemParameter
|
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.
|
// This function assumes each query key only appears once.
|
||||||
func (q *Query) Paramify() map[string]string {
|
func (q *Query) Paramify() map[string]string {
|
||||||
params := map[string]string{}
|
params := map[string]string{}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
log "github.com/rs/zerolog/log"
|
log "github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Setup makes the initial requests to set up the session cookies for the application.
|
||||||
func (a *API) Setup() {
|
func (a *API) Setup() {
|
||||||
// Makes the initial requests that sets up the session cookies for the rest of the application
|
// Makes the initial requests that sets up the session cookies for the rest of the application
|
||||||
log.Info().Msg("Setting up session...")
|
log.Info().Msg("Setting up session...")
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
Fall
|
Fall
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Term represents a school term, consisting of a year and a season.
|
||||||
type Term struct {
|
type Term struct {
|
||||||
Year uint16
|
Year uint16
|
||||||
Season uint8
|
Season uint8
|
||||||
@@ -32,16 +33,14 @@ func init() {
|
|||||||
SpringRange, SummerRange, FallRange = GetYearDayRange(loc, uint16(time.Now().Year()))
|
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 {
|
type YearDayRange struct {
|
||||||
Start uint16
|
Start uint16
|
||||||
End uint16
|
End uint16
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetYearDayRange returns the start and end day of each term for the given year.
|
// 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.
|
// The ranges are inclusive of the start day and exclusive of the end day.
|
||||||
// Spring: January 14th to May
|
|
||||||
// Summer: May 25th - August 15th
|
|
||||||
// Fall: August 18th - December 10th
|
|
||||||
func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRange, YearDayRange) {
|
func GetYearDayRange(loc *time.Location, year uint16) (YearDayRange, YearDayRange, YearDayRange) {
|
||||||
springStart := time.Date(int(year), time.January, 14, 0, 0, 0, 0, loc).YearDay()
|
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()
|
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.
|
// GetCurrentTerm returns the current and next terms based on the provided time.
|
||||||
// YearDay ranges are inclusive of the start, and exclusive of the end.
|
// The current term can be nil if the time falls between terms.
|
||||||
// You can think of the 'year' part of it as the 'school year', the second part of the 20XX-(20XX+1) phrasing.
|
// The 'year' in the term corresponds to the academic year, which may differ from the calendar year.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
func GetCurrentTerm(now time.Time) (*Term, *Term) {
|
func GetCurrentTerm(now time.Time) (*Term, *Term) {
|
||||||
literalYear := uint16(now.Year())
|
literalYear := uint16(now.Year())
|
||||||
dayOfYear := uint16(now.YearDay())
|
dayOfYear := uint16(now.YearDay())
|
||||||
@@ -112,7 +99,7 @@ func GetCurrentTerm(now time.Time) (*Term, *Term) {
|
|||||||
panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear))
|
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 {
|
func ParseTerm(code string) Term {
|
||||||
year, _ := strconv.ParseUint(code[0:4], 10, 16)
|
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 {
|
func (term Term) ToString() string {
|
||||||
var season string
|
var season string
|
||||||
switch term.Season {
|
switch term.Season {
|
||||||
@@ -148,7 +135,7 @@ func (term Term) ToString() string {
|
|||||||
return fmt.Sprintf("%d%s", term.Year, season)
|
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 {
|
func Default(t time.Time) Term {
|
||||||
currentTerm, nextTerm := GetCurrentTerm(t)
|
currentTerm, nextTerm := GetCurrentTerm(t)
|
||||||
if currentTerm == nil {
|
if currentTerm == nil {
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ const (
|
|||||||
ICalTimestampFormatLocal = "20060102T150405"
|
ICalTimestampFormatLocal = "20060102T150405"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CommandHandler is a function that handles a slash command interaction.
|
||||||
type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error
|
type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// CommandDefinitions is a list of all the bot's command definitions.
|
||||||
CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition}
|
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,
|
TimeCommandDefinition.Name: TimeCommandHandler,
|
||||||
TermCommandDefinition.Name: TermCommandHandler,
|
TermCommandDefinition.Name: TermCommandHandler,
|
||||||
SearchCommandDefinition.Name: SearchCommandHandler,
|
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 {
|
func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
|
||||||
data := i.ApplicationCommandData()
|
data := i.ApplicationCommandData()
|
||||||
query := api.NewQuery().Credits(3, 6)
|
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 {
|
func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
|
||||||
data := i.ApplicationCommandData()
|
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 {
|
func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
|
||||||
fetch_time := time.Now()
|
fetch_time := time.Now()
|
||||||
crn := i.ApplicationCommandData().Options[0].IntValue()
|
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 {
|
func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error {
|
||||||
// Parse all options
|
// Parse all options
|
||||||
options := internal.ParseOptions(i.ApplicationCommandData().Options)
|
options := internal.ParseOptions(i.ApplicationCommandData().Options)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RegisterHandlers registers the bot's command handlers.
|
||||||
func (b *Bot) RegisterHandlers() {
|
func (b *Bot) RegisterHandlers() {
|
||||||
b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) {
|
b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) {
|
||||||
// Handle commands during restart (highly unlikely, but just in case)
|
// 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
|
package bot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bot represents the state of the Discord bot.
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
Session *discordgo.Session
|
Session *discordgo.Session
|
||||||
API *api.API
|
API *api.API
|
||||||
@@ -17,14 +19,17 @@ type Bot struct {
|
|||||||
isClosing bool
|
isClosing bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New creates a new Bot instance.
|
||||||
func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot {
|
func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot {
|
||||||
return &Bot{Session: s, API: a, Config: c}
|
return &Bot{Session: s, API: a, Config: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetClosing marks the bot as closing, preventing new commands from being processed.
|
||||||
func (b *Bot) SetClosing() {
|
func (b *Bot) SetClosing() {
|
||||||
b.isClosing = true
|
b.isClosing = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSession ensures a valid session is available and selects the default term.
|
||||||
func (b *Bot) GetSession() (string, error) {
|
func (b *Bot) GetSession() (string, error) {
|
||||||
sessionID := b.API.EnsureSession()
|
sessionID := b.API.EnsureSession()
|
||||||
term := api.Default(time.Now()).ToString()
|
term := api.Default(time.Now()).ToString()
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ import (
|
|||||||
"resty.dev/v3"
|
"resty.dev/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config holds the application's configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Ctx context.Context
|
// Ctx is the application's root context.
|
||||||
CancelFunc context.CancelFunc
|
Ctx context.Context
|
||||||
KV *redis.Client
|
// CancelFunc cancels the application's root context.
|
||||||
Client *resty.Client
|
CancelFunc context.CancelFunc
|
||||||
IsDevelopment bool
|
// KV provides access to the Redis cache.
|
||||||
BaseURL string
|
KV *redis.Client
|
||||||
Environment string
|
// 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
|
CentralTimeLocation *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +32,11 @@ const (
|
|||||||
CentralTimezoneName = "America/Chicago"
|
CentralTimezoneName = "America/Chicago"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New creates a new Config instance with a cancellable context.
|
||||||
func New() (*Config, error) {
|
func New() (*Config, error) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
loc, err := time.LoadLocation(CentralTimezoneName)
|
loc, err := time.LoadLocation("America/Chicago")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -39,23 +49,23 @@ func New() (*Config, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBaseURL sets the base URL for API requests
|
// SetBaseURL sets the base URL for the Banner API.
|
||||||
func (c *Config) SetBaseURL(url string) {
|
func (c *Config) SetBaseURL(url string) {
|
||||||
c.BaseURL = url
|
c.BaseURL = url
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetEnvironment sets the environment
|
// SetEnvironment sets the application's environment.
|
||||||
func (c *Config) SetEnvironment(env string) {
|
func (c *Config) SetEnvironment(env string) {
|
||||||
c.Environment = env
|
c.Environment = env
|
||||||
c.IsDevelopment = env == "development"
|
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) {
|
func (c *Config) SetClient(client *resty.Client) {
|
||||||
c.Client = client
|
c.Client = client
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRedis sets the Redis client
|
// SetRedis sets the Redis client for caching.
|
||||||
func (c *Config) SetRedis(r *redis.Client) {
|
func (c *Config) SetRedis(r *redis.Client) {
|
||||||
c.KV = r
|
c.KV = r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config provides the configuration and logging setup for the application.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,12 +10,7 @@ import (
|
|||||||
|
|
||||||
const timeFormat = "2006-01-02 15:04:05"
|
const timeFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
var (
|
// NewConsoleWriter creates a new console writer that splits logs between stdout and stderr.
|
||||||
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
|
|
||||||
func NewConsoleWriter() zerolog.LevelWriter {
|
func NewConsoleWriter() zerolog.LevelWriter {
|
||||||
return &ConsoleLogSplitter{
|
return &ConsoleLogSplitter{
|
||||||
stdConsole: zerolog.ConsoleWriter{
|
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 {
|
type ConsoleLogSplitter struct {
|
||||||
stdConsole zerolog.ConsoleWriter
|
stdConsole zerolog.ConsoleWriter
|
||||||
errConsole 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) {
|
func (c *ConsoleLogSplitter) Write(p []byte) (n int, err error) {
|
||||||
return c.stdConsole.Write(p)
|
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) {
|
func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) {
|
||||||
if level <= zerolog.WarnLevel {
|
if level <= zerolog.WarnLevel {
|
||||||
return c.stdConsole.Write(p)
|
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)
|
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 {
|
type LogSplitter struct {
|
||||||
Std io.Writer
|
Std io.Writer
|
||||||
Err 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) {
|
func (l LogSplitter) Write(p []byte) (n int, err error) {
|
||||||
return l.Std.Write(p)
|
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) {
|
func (l LogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) {
|
||||||
if level <= zerolog.WarnLevel {
|
if level <= zerolog.WarnLevel {
|
||||||
return l.Std.Write(p)
|
return l.Std.Write(p)
|
||||||
} else {
|
|
||||||
return l.Err.Write(p)
|
|
||||||
}
|
}
|
||||||
|
return l.Err.Write(p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package internal
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
|
// UnexpectedContentTypeError is returned when the Content-Type header of a response does not match the expected value.
|
||||||
type UnexpectedContentTypeError struct {
|
type UnexpectedContentTypeError struct {
|
||||||
Expected string
|
Expected string
|
||||||
Actual string
|
Actual string
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import (
|
|||||||
"banner/internal/config"
|
"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
|
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 {
|
func (o Options) GetInt(key string) int64 {
|
||||||
if opt, ok := o[key]; ok {
|
if opt, ok := o[key]; ok {
|
||||||
return opt.IntValue()
|
return opt.IntValue()
|
||||||
@@ -32,7 +32,7 @@ func (o Options) GetInt(key string) int64 {
|
|||||||
return 0
|
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 {
|
func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options {
|
||||||
optionMap := make(Options)
|
optionMap := make(Options)
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
@@ -41,12 +41,12 @@ func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption)
|
|||||||
return optionMap
|
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) {
|
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")
|
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 {
|
func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
|
||||||
contentType := res.Header().Get("Content-Type")
|
contentType := res.Header().Get("Content-Type")
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
@@ -57,8 +57,8 @@ func ContentTypeMatch(res *resty.Response, expectedContentType string) bool {
|
|||||||
|
|
||||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
|
||||||
// RandomString returns a random string of length n using the letterBytes constant
|
// RandomString returns a random string of length n.
|
||||||
// The constant used is specifically chosen to mimic Ellucian's banner session ID generation.
|
// The character set is chosen to mimic Ellucian's Banner session ID generation.
|
||||||
func RandomString(n int) string {
|
func RandomString(n int) string {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
for i := range b {
|
for i := range b {
|
||||||
@@ -67,8 +67,7 @@ func RandomString(n int) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscordGoLogger is a specialized helper function that implements discordgo's global logging interface.
|
// DiscordGoLogger is a helper function that implements discordgo's logging interface, directing all logs to zerolog.
|
||||||
// It directs all logs to the zerolog implementation.
|
|
||||||
func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
|
func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) {
|
||||||
pc, file, line, _ := runtime.Caller(caller)
|
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)
|
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
|
// 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 in the browser.
|
// This is typically used as a query parameter to prevent request caching.
|
||||||
func Nonce() string {
|
func Nonce() string {
|
||||||
return strconv.Itoa(int(time.Now().UnixMilli()))
|
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 {
|
func Plural(n int) string {
|
||||||
if n == 1 {
|
if n == 1 {
|
||||||
return ""
|
return ""
|
||||||
@@ -112,8 +111,7 @@ func Plural(n int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plurale is a simple helper function that returns an empty string if n is 1, and "ess" otherwise.
|
// Plurale returns "es" if n is not 1.
|
||||||
// This is for words that end in "es" when plural.
|
|
||||||
func Plurale(n int) string {
|
func Plurale(n int) string {
|
||||||
if n == 1 {
|
if n == 1 {
|
||||||
return ""
|
return ""
|
||||||
@@ -121,6 +119,7 @@ func Plurale(n int) string {
|
|||||||
return "es"
|
return "es"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WeekdaysToString converts a map of weekdays to a compact string representation (e.g., "MWF").
|
||||||
func WeekdaysToString(days map[time.Weekday]bool) string {
|
func WeekdaysToString(days map[time.Weekday]bool) string {
|
||||||
// If no days are present
|
// If no days are present
|
||||||
numDays := len(days)
|
numDays := len(days)
|
||||||
@@ -166,15 +165,18 @@ func WeekdaysToString(days map[time.Weekday]bool) string {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NaiveTime represents a time of day without a date or timezone.
|
||||||
type NaiveTime struct {
|
type NaiveTime struct {
|
||||||
Hours uint
|
Hours uint
|
||||||
Minutes uint
|
Minutes uint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub returns the duration between two NaiveTime instances.
|
||||||
func (nt *NaiveTime) Sub(other *NaiveTime) time.Duration {
|
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)
|
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 {
|
func ParseNaiveTime(integer uint64) *NaiveTime {
|
||||||
minutes := uint(integer % 100)
|
minutes := uint(integer % 100)
|
||||||
hours := uint(integer / 100)
|
hours := uint(integer / 100)
|
||||||
@@ -182,6 +184,7 @@ func ParseNaiveTime(integer uint64) *NaiveTime {
|
|||||||
return &NaiveTime{Hours: hours, Minutes: minutes}
|
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 {
|
func (nt NaiveTime) String() string {
|
||||||
meridiem := "AM"
|
meridiem := "AM"
|
||||||
hour := nt.Hours
|
hour := nt.Hours
|
||||||
@@ -194,6 +197,7 @@ func (nt NaiveTime) String() string {
|
|||||||
return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem)
|
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 {
|
func GetFirstEnv(key ...string) string {
|
||||||
for _, k := range key {
|
for _, k := range key {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
@@ -203,14 +207,12 @@ func GetFirstEnv(key ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIntPointer returns a pointer to the given value.
|
// GetIntPointer returns a pointer to the given integer.
|
||||||
// This function is useful for discordgo, which inexplicably requires pointers to integers for minLength arguments.
|
|
||||||
func GetIntPointer(value int) *int {
|
func GetIntPointer(value int) *int {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFloatPointer returns a pointer to the given value.
|
// GetFloatPointer returns a pointer to the given float.
|
||||||
// This function is useful for discordgo, which inexplicably requires pointers to floats for minLength arguments.
|
|
||||||
func GetFloatPointer(value float64) *float64 {
|
func GetFloatPointer(value float64) *float64 {
|
||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
@@ -244,6 +246,7 @@ var extensionMap = map[string]string{
|
|||||||
"image/jxl": "jxl",
|
"image/jxl": "jxl",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GuessExtension guesses the file extension for a given content type.
|
||||||
func GuessExtension(contentType string) string {
|
func GuessExtension(contentType string) string {
|
||||||
ext, ok := extensionMap[strings.ToLower(contentType)]
|
ext, ok := extensionMap[strings.ToLower(contentType)]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -252,7 +255,7 @@ func GuessExtension(contentType string) string {
|
|||||||
return ext
|
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) {
|
func DumpResponse(res *resty.Response) {
|
||||||
contentType := res.Header().Get("Content-Type")
|
contentType := res.Header().Get("Content-Type")
|
||||||
ext := GuessExtension(contentType)
|
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")
|
log.Info().Str("filename", filename).Str("content-type", contentType).Msg("Dumped response body")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResponseError responds to an interaction with an error message
|
// RespondError responds to an interaction with a formatted error message.
|
||||||
// TODO: Improve with a proper embed and colors
|
|
||||||
func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error {
|
func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error {
|
||||||
// Optional: log the error
|
// Optional: log the error
|
||||||
if err != nil {
|
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 {
|
func GetFetchedFooter(cfg *config.Config, time time.Time) *discordgo.MessageEmbedFooter {
|
||||||
return &discordgo.MessageEmbedFooter{
|
return &discordgo.MessageEmbedFooter{
|
||||||
Text: fmt.Sprintf("Fetched at %s", time.In(cfg.CentralTimeLocation).Format("Monday, January 2, 2006 at 3:04:05PM")),
|
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.
|
// GetUser returns the user from an interaction, regardless of whether it was in a guild or a DM.
|
||||||
// This helper method is useful as depending on where the message was sent (guild or DM), the user is in a different field.
|
|
||||||
func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User {
|
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 {
|
if interaction.Member != nil {
|
||||||
return interaction.Member.User
|
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
|
return interaction.User
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode encodes the values into “URL encoded” form
|
// EncodeParams encodes a map of parameters into a URL-encoded string, sorted by key.
|
||||||
// ("bar=baz&foo=quux") sorted by key.
|
|
||||||
func EncodeParams(params map[string]*[]string) string {
|
func EncodeParams(params map[string]*[]string) string {
|
||||||
// Escape hatch for nil
|
// Escape hatch for nil
|
||||||
if params == nil {
|
if params == nil {
|
||||||
@@ -362,11 +363,12 @@ func EncodeParams(params map[string]*[]string) string {
|
|||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point represents a point in 2D space
|
// Point represents a point in 2D space.
|
||||||
type Point struct {
|
type Point struct {
|
||||||
X, Y float64
|
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 {
|
func Slope(p1 Point, p2 Point, x float64) Point {
|
||||||
slope := (p2.Y - p1.Y) / (p2.X - p1.X)
|
slope := (p2.Y - p1.Y) / (p2.X - p1.X)
|
||||||
newY := slope*(x-p1.X) + p1.Y
|
newY := slope*(x-p1.X) + p1.Y
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package internal provides shared functionality for the banner application.
|
||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,7 +11,7 @@ import (
|
|||||||
log "github.com/rs/zerolog/log"
|
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 {
|
func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string) string {
|
||||||
// Create a timeout context for Redis operations
|
// Create a timeout context for Redis operations
|
||||||
ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second)
|
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
|
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 {
|
func GetChannelName(cfg *config.Config, session *discordgo.Session, channelID string) string {
|
||||||
// Create a timeout context for Redis operations
|
// Create a timeout context for Redis operations
|
||||||
ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second)
|
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
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,8 +12,9 @@ import (
|
|||||||
log "github.com/rs/zerolog/log"
|
log "github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FacultyItem represents a faculty member associated with a course.
|
||||||
type FacultyItem struct {
|
type FacultyItem struct {
|
||||||
BannerId string `json:"bannerId"`
|
BannerID string `json:"bannerId"`
|
||||||
Category *string `json:"category"`
|
Category *string `json:"category"`
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
CourseReferenceNumber string `json:"courseReferenceNumber"`
|
CourseReferenceNumber string `json:"courseReferenceNumber"`
|
||||||
@@ -22,6 +24,7 @@ type FacultyItem struct {
|
|||||||
Term string `json:"term"`
|
Term string `json:"term"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MeetingTimeResponse represents the meeting time information for a course.
|
||||||
type MeetingTimeResponse struct {
|
type MeetingTimeResponse struct {
|
||||||
Category *string `json:"category"`
|
Category *string `json:"category"`
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
@@ -80,6 +83,7 @@ type MeetingTimeResponse struct {
|
|||||||
Term string `json:"term"`
|
Term string `json:"term"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String returns a formatted string representation of the meeting time.
|
||||||
func (m *MeetingTimeResponse) String() string {
|
func (m *MeetingTimeResponse) String() string {
|
||||||
switch m.MeetingTime.MeetingType {
|
switch m.MeetingTime.MeetingType {
|
||||||
case "HB":
|
case "HB":
|
||||||
@@ -104,6 +108,7 @@ func (m *MeetingTimeResponse) String() string {
|
|||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TimeString returns a formatted string of the meeting times (e.g., "MWF 1:00PM-2:15PM").
|
||||||
func (m *MeetingTimeResponse) TimeString() string {
|
func (m *MeetingTimeResponse) TimeString() string {
|
||||||
startTime := m.StartTime()
|
startTime := m.StartTime()
|
||||||
endTime := m.EndTime()
|
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())
|
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 {
|
func (m *MeetingTimeResponse) PlaceString() string {
|
||||||
mt := m.MeetingTime
|
mt := m.MeetingTime
|
||||||
|
|
||||||
// TODO: ADd format case for partial online classes
|
// TODO: Add format case for partial online classes
|
||||||
if mt.Room == "" {
|
if mt.Room == "" {
|
||||||
return "Online"
|
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)
|
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 {
|
func (m *MeetingTimeResponse) Days() map[time.Weekday]bool {
|
||||||
days := map[time.Weekday]bool{}
|
days := map[time.Weekday]bool{}
|
||||||
|
|
||||||
@@ -140,7 +146,7 @@ func (m *MeetingTimeResponse) Days() map[time.Weekday]bool {
|
|||||||
return days
|
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 {
|
func (m *MeetingTimeResponse) ByDay() string {
|
||||||
days := []string{}
|
days := []string{}
|
||||||
|
|
||||||
@@ -171,8 +177,8 @@ func (m *MeetingTimeResponse) ByDay() string {
|
|||||||
|
|
||||||
const layout = "01/02/2006"
|
const layout = "01/02/2006"
|
||||||
|
|
||||||
// StartDay returns the start date of the meeting time as a time.Time object
|
// StartDay returns the start date of the meeting as a time.Time object.
|
||||||
// This is not cached and is parsed on each invocation. It may also panic without handling.
|
// This method is not cached and will panic if the date cannot be parsed.
|
||||||
func (m *MeetingTimeResponse) StartDay() time.Time {
|
func (m *MeetingTimeResponse) StartDay() time.Time {
|
||||||
t, err := time.Parse(layout, m.MeetingTime.StartDate)
|
t, err := time.Parse(layout, m.MeetingTime.StartDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -181,8 +187,8 @@ func (m *MeetingTimeResponse) StartDay() time.Time {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndDay returns the end date of the meeting time as a time.Time object.
|
// EndDay returns the end date of the meeting as a time.Time object.
|
||||||
// This is not cached and is parsed on each invocation. It may also panic without handling.
|
// This method is not cached and will panic if the date cannot be parsed.
|
||||||
func (m *MeetingTimeResponse) EndDay() time.Time {
|
func (m *MeetingTimeResponse) EndDay() time.Time {
|
||||||
t, err := time.Parse(layout, m.MeetingTime.EndDate)
|
t, err := time.Parse(layout, m.MeetingTime.EndDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,8 +197,8 @@ func (m *MeetingTimeResponse) EndDay() time.Time {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTime returns the start time of the meeting time as a NaiveTime object
|
// StartTime returns the start time of the meeting as a NaiveTime object.
|
||||||
// This is not cached and is parsed on each invocation. It may also panic without handling.
|
// This method is not cached and will panic if the time cannot be parsed.
|
||||||
func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
|
func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
|
||||||
raw := m.MeetingTime.BeginTime
|
raw := m.MeetingTime.BeginTime
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -207,8 +213,8 @@ func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime {
|
|||||||
return internal.ParseNaiveTime(value)
|
return internal.ParseNaiveTime(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndTime returns the end time of the meeting time as a NaiveTime object
|
// EndTime returns the end time of the meeting as a NaiveTime object.
|
||||||
// This is not cached and is parsed on each invocation. It may also panic without handling.
|
// This method is not cached and will panic if the time cannot be parsed.
|
||||||
func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
|
func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
|
||||||
raw := m.MeetingTime.EndTime
|
raw := m.MeetingTime.EndTime
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -223,12 +229,13 @@ func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime {
|
|||||||
return internal.ParseNaiveTime(value)
|
return internal.ParseNaiveTime(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RRule represents a recurrence rule for an iCalendar event.
|
||||||
type RRule struct {
|
type RRule struct {
|
||||||
Until string
|
Until string
|
||||||
ByDay 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 {
|
func (m *MeetingTimeResponse) RRule() RRule {
|
||||||
return RRule{
|
return RRule{
|
||||||
Until: m.EndDay().UTC().Format("20060102T150405Z"),
|
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 {
|
type SearchResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
@@ -249,41 +257,36 @@ type SearchResult struct {
|
|||||||
Data []Course `json:"data"`
|
Data []Course `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Course represents a single course returned from a search.
|
||||||
type Course struct {
|
type Course struct {
|
||||||
// A internal identifier not used outside of the Banner system
|
// ID is an internal identifier not used outside of the Banner system.
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
// The internal identifier for the term this class is in (e.g. 202420)
|
// Term is the internal identifier for the term this class is in (e.g. 202420).
|
||||||
Term string `json:"term"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
ScheduleTypeDescription string `json:"scheduleTypeDescription"`
|
||||||
// The long name of the course (generally)
|
CourseTitle string `json:"courseTitle"`
|
||||||
CourseTitle string `json:"courseTitle"`
|
CreditHours int `json:"creditHours"`
|
||||||
CreditHours int `json:"creditHours"`
|
// MaximumEnrollment is the maximum number of students that can enroll.
|
||||||
// The maximum number of students that can enroll in this course
|
MaximumEnrollment int `json:"maximumEnrollment"`
|
||||||
MaximumEnrollment int `json:"maximumEnrollment"`
|
Enrollment int `json:"enrollment"`
|
||||||
// The number of students currently enrolled in this course
|
SeatsAvailable int `json:"seatsAvailable"`
|
||||||
Enrollment int `json:"enrollment"`
|
WaitCapacity int `json:"waitCapacity"`
|
||||||
// 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"`
|
WaitCount int `json:"waitCount"`
|
||||||
CrossList *string `json:"crossList"`
|
CrossList *string `json:"crossList"`
|
||||||
CrossListCapacity *int `json:"crossListCapacity"`
|
CrossListCapacity *int `json:"crossListCapacity"`
|
||||||
@@ -295,27 +298,26 @@ type Course struct {
|
|||||||
OpenSection bool `json:"openSection"`
|
OpenSection bool `json:"openSection"`
|
||||||
LinkIdentifier *string `json:"linkIdentifier"`
|
LinkIdentifier *string `json:"linkIdentifier"`
|
||||||
IsSectionLinked bool `json:"isSectionLinked"`
|
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"`
|
SubjectCourse string `json:"subjectCourse"`
|
||||||
ReservedSeatSummary *string `json:"reservedSeatSummary"`
|
ReservedSeatSummary *string `json:"reservedSeatSummary"`
|
||||||
InstructionalMethod string `json:"instructionalMethod"`
|
InstructionalMethod string `json:"instructionalMethod"`
|
||||||
InstructionalMethodDescription string `json:"instructionalMethodDescription"`
|
InstructionalMethodDescription string `json:"instructionalMethodDescription"`
|
||||||
SectionAttributes []struct {
|
SectionAttributes []struct {
|
||||||
// A internal API class identifier used by Banner
|
// Class is an internal API class identifier used by Banner.
|
||||||
Class string `json:"class"`
|
Class string `json:"class"`
|
||||||
CourseReferenceNumber string `json:"courseReferenceNumber"`
|
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"`
|
Code string `json:"code"`
|
||||||
// Seems to be the fully qualified meaning of the Code (Upper, Intensive English Program...)
|
Description string `json:"description"`
|
||||||
Description string `json:"description"`
|
TermCode string `json:"termCode"`
|
||||||
TermCode string `json:"termCode"`
|
IsZtcAttribute bool `json:"isZTCAttribute"`
|
||||||
// Unknown; always false
|
|
||||||
IsZtcAttribute bool `json:"isZTCAttribute"`
|
|
||||||
} `json:"sectionAttributes"`
|
} `json:"sectionAttributes"`
|
||||||
Faculty []FacultyItem `json:"faculty"`
|
Faculty []FacultyItem `json:"faculty"`
|
||||||
MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"`
|
MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalBinary implements the encoding.BinaryMarshaler interface.
|
||||||
func (course Course) MarshalBinary() ([]byte, error) {
|
func (course Course) MarshalBinary() ([]byte, error) {
|
||||||
return json.Marshal(course)
|
return json.Marshal(course)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user