mirror of
https://github.com/Xevion/banner.git
synced 2025-12-10 04:06:37 -06:00
feat!: first pass re-implementation of banner, gcal command
This commit is contained in:
24
src/app_state.rs
Normal file
24
src/app_state.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! Application state shared across components (bot, web, scheduler).
|
||||
|
||||
use crate::banner::BannerApi;
|
||||
use redis::Client;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub banner_api: std::sync::Arc<BannerApi>,
|
||||
pub redis_client: std::sync::Arc<Client>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
banner_api: BannerApi,
|
||||
redis_url: &str,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let redis_client = Client::open(redis_url)?;
|
||||
|
||||
Ok(Self {
|
||||
banner_api: std::sync::Arc::new(banner_api),
|
||||
redis_client: std::sync::Arc::new(redis_client),
|
||||
})
|
||||
}
|
||||
}
|
||||
320
src/banner/api.rs
Normal file
320
src/banner/api.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! Main Banner API client implementation.
|
||||
|
||||
use crate::banner::{SessionManager, models::*, query::SearchQuery};
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde_json;
|
||||
|
||||
// use tracing::debug;
|
||||
|
||||
/// Main Banner API client.
|
||||
#[derive(Debug)]
|
||||
pub struct BannerApi {
|
||||
session_manager: SessionManager,
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl BannerApi {
|
||||
/// Creates a new Banner API client.
|
||||
pub fn new(base_url: String) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.cookie_store(true)
|
||||
.user_agent(user_agent())
|
||||
.tcp_keepalive(Some(std::time::Duration::from_secs(60 * 5)))
|
||||
.read_timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let session_manager = SessionManager::new(base_url.clone(), client.clone());
|
||||
|
||||
Ok(Self {
|
||||
session_manager,
|
||||
client,
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets up the API client by initializing session cookies.
|
||||
pub async fn setup(&self) -> Result<()> {
|
||||
self.session_manager.setup().await
|
||||
}
|
||||
|
||||
/// Retrieves a list of terms from the Banner API.
|
||||
pub async fn get_terms(
|
||||
&self,
|
||||
search: &str,
|
||||
page: i32,
|
||||
max_results: i32,
|
||||
) -> Result<Vec<BannerTerm>> {
|
||||
if page <= 0 {
|
||||
return Err(anyhow::anyhow!("Page must be greater than 0"));
|
||||
}
|
||||
|
||||
let url = format!("{}/classSearch/getTerms", self.base_url);
|
||||
let params = [
|
||||
("searchTerm", search),
|
||||
("offset", &page.to_string()),
|
||||
("max", &max_results.to_string()),
|
||||
("_", ×tamp_nonce()),
|
||||
];
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get terms")?;
|
||||
|
||||
let terms: Vec<BannerTerm> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse terms response")?;
|
||||
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// Retrieves a list of subjects from the Banner API.
|
||||
pub async fn get_subjects(
|
||||
&self,
|
||||
search: &str,
|
||||
term: &str,
|
||||
offset: i32,
|
||||
max_results: i32,
|
||||
) -> Result<Vec<Pair>> {
|
||||
if offset <= 0 {
|
||||
return Err(anyhow::anyhow!("Offset must be greater than 0"));
|
||||
}
|
||||
|
||||
let session_id = self.session_manager.ensure_session()?;
|
||||
let url = format!("{}/classSearch/get_subject", self.base_url);
|
||||
let params = [
|
||||
("searchTerm", search),
|
||||
("term", term),
|
||||
("offset", &offset.to_string()),
|
||||
("max", &max_results.to_string()),
|
||||
("uniqueSessionId", &session_id),
|
||||
("_", ×tamp_nonce()),
|
||||
];
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get subjects")?;
|
||||
|
||||
let subjects: Vec<Pair> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse subjects response")?;
|
||||
|
||||
Ok(subjects)
|
||||
}
|
||||
|
||||
/// Retrieves a list of instructors from the Banner API.
|
||||
pub async fn get_instructors(
|
||||
&self,
|
||||
search: &str,
|
||||
term: &str,
|
||||
offset: i32,
|
||||
max_results: i32,
|
||||
) -> Result<Vec<Instructor>> {
|
||||
if offset <= 0 {
|
||||
return Err(anyhow::anyhow!("Offset must be greater than 0"));
|
||||
}
|
||||
|
||||
let session_id = self.session_manager.ensure_session()?;
|
||||
let url = format!("{}/classSearch/get_instructor", self.base_url);
|
||||
let params = [
|
||||
("searchTerm", search),
|
||||
("term", term),
|
||||
("offset", &offset.to_string()),
|
||||
("max", &max_results.to_string()),
|
||||
("uniqueSessionId", &session_id),
|
||||
("_", ×tamp_nonce()),
|
||||
];
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get instructors")?;
|
||||
|
||||
let instructors: Vec<Instructor> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse instructors response")?;
|
||||
|
||||
Ok(instructors)
|
||||
}
|
||||
|
||||
/// Retrieves a list of campuses from the Banner API.
|
||||
pub async fn get_campuses(
|
||||
&self,
|
||||
search: &str,
|
||||
term: i32,
|
||||
offset: i32,
|
||||
max_results: i32,
|
||||
) -> Result<Vec<Pair>> {
|
||||
if offset <= 0 {
|
||||
return Err(anyhow::anyhow!("Offset must be greater than 0"));
|
||||
}
|
||||
|
||||
let session_id = self.session_manager.ensure_session()?;
|
||||
let url = format!("{}/classSearch/get_campus", self.base_url);
|
||||
let params = [
|
||||
("searchTerm", search),
|
||||
("term", &term.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
("max", &max_results.to_string()),
|
||||
("uniqueSessionId", &session_id),
|
||||
("_", ×tamp_nonce()),
|
||||
];
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get campuses")?;
|
||||
|
||||
let campuses: Vec<Pair> = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse campuses response")?;
|
||||
|
||||
Ok(campuses)
|
||||
}
|
||||
|
||||
/// Retrieves meeting time information for a course.
|
||||
pub async fn get_course_meeting_time(
|
||||
&self,
|
||||
term: i32,
|
||||
crn: i32,
|
||||
) -> Result<Vec<MeetingTimeResponse>> {
|
||||
let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url);
|
||||
let params = [
|
||||
("term", &term.to_string()),
|
||||
("courseReferenceNumber", &crn.to_string()),
|
||||
];
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get meeting times")?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ResponseWrapper {
|
||||
fmt: Vec<MeetingTimeResponse>,
|
||||
}
|
||||
|
||||
let wrapper: ResponseWrapper = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse meeting times response")?;
|
||||
|
||||
Ok(wrapper.fmt)
|
||||
}
|
||||
|
||||
/// Performs a search for courses.
|
||||
pub async fn search(
|
||||
&self,
|
||||
term: &str,
|
||||
query: &SearchQuery,
|
||||
sort: &str,
|
||||
sort_descending: bool,
|
||||
) -> Result<SearchResult> {
|
||||
self.session_manager.reset_data_form().await?;
|
||||
|
||||
let session_id = self.session_manager.ensure_session()?;
|
||||
let mut params = query.to_params();
|
||||
|
||||
// Add additional parameters
|
||||
params.insert("txt_term".to_string(), term.to_string());
|
||||
params.insert("uniqueSessionId".to_string(), session_id);
|
||||
params.insert("sortColumn".to_string(), sort.to_string());
|
||||
params.insert(
|
||||
"sortDirection".to_string(),
|
||||
if sort_descending { "desc" } else { "asc" }.to_string(),
|
||||
);
|
||||
params.insert("startDatepicker".to_string(), String::new());
|
||||
params.insert("endDatepicker".to_string(), String::new());
|
||||
|
||||
let url = format!("{}/searchResults/searchResults", self.base_url);
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to search courses")?;
|
||||
|
||||
let search_result: SearchResult = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse search response")?;
|
||||
|
||||
if !search_result.success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Search marked as unsuccessful by Banner API"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(search_result)
|
||||
}
|
||||
|
||||
/// Selects a term for the current session.
|
||||
pub async fn select_term(&self, term: &str) -> Result<()> {
|
||||
self.session_manager.select_term(term).await
|
||||
}
|
||||
|
||||
/// Gets course details (placeholder - needs implementation).
|
||||
pub async fn get_course_details(&self, term: i32, crn: i32) -> Result<ClassDetails> {
|
||||
let body = serde_json::json!({
|
||||
"term": term.to_string(),
|
||||
"courseReferenceNumber": crn.to_string(),
|
||||
"first": "first"
|
||||
});
|
||||
|
||||
let url = format!("{}/searchResults/getClassDetails", self.base_url);
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get course details")?;
|
||||
|
||||
let details: ClassDetails = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse course details response")?;
|
||||
|
||||
Ok(details)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a timestamp-based nonce.
|
||||
fn timestamp_nonce() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Returns a browser-like user agent string.
|
||||
fn user_agent() -> &'static str {
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
|
||||
}
|
||||
19
src/banner/mod.rs
Normal file
19
src/banner/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Banner API module for interacting with Ellucian Banner systems.
|
||||
//!
|
||||
//! This module provides functionality to:
|
||||
//! - Search for courses and retrieve course information
|
||||
//! - Manage Banner API sessions and authentication
|
||||
//! - Scrape course data and cache it in Redis
|
||||
//! - Generate ICS files and calendar links
|
||||
|
||||
pub mod api;
|
||||
pub mod models;
|
||||
pub mod query;
|
||||
pub mod scraper;
|
||||
pub mod session;
|
||||
|
||||
pub use api::BannerApi;
|
||||
pub use models::*;
|
||||
pub use query::SearchQuery;
|
||||
pub use scraper::CourseScraper;
|
||||
pub use session::SessionManager;
|
||||
21
src/banner/models/common.rs
Normal file
21
src/banner/models/common.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Represents a key-value pair from the Banner API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pair {
|
||||
pub code: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Represents a term in the Banner system
|
||||
pub type BannerTerm = Pair;
|
||||
|
||||
/// Represents an instructor in the Banner system
|
||||
pub type Instructor = Pair;
|
||||
|
||||
impl BannerTerm {
|
||||
/// Returns true if the term is in an archival (view-only) state
|
||||
pub fn is_archived(&self) -> bool {
|
||||
self.description.contains("View Only")
|
||||
}
|
||||
}
|
||||
64
src/banner/models/courses.rs
Normal file
64
src/banner/models/courses.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::meetings::FacultyItem;
|
||||
use super::meetings::MeetingTimeResponse;
|
||||
|
||||
/// Course section attribute
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SectionAttribute {
|
||||
pub class: String,
|
||||
pub course_reference_number: String,
|
||||
pub code: String,
|
||||
pub description: String,
|
||||
pub term_code: String,
|
||||
#[serde(rename = "isZTCAttribute")]
|
||||
pub is_ztc_attribute: bool,
|
||||
}
|
||||
|
||||
/// Represents a single course returned from a search
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Course {
|
||||
pub id: i32,
|
||||
pub term: String,
|
||||
pub term_desc: String,
|
||||
pub course_reference_number: String,
|
||||
pub part_of_term: String,
|
||||
pub course_number: String,
|
||||
pub subject: String,
|
||||
pub subject_description: String,
|
||||
pub sequence_number: String,
|
||||
pub campus_description: String,
|
||||
pub schedule_type_description: String,
|
||||
pub course_title: String,
|
||||
pub credit_hours: i32,
|
||||
pub maximum_enrollment: i32,
|
||||
pub enrollment: i32,
|
||||
pub seats_available: i32,
|
||||
pub wait_capacity: i32,
|
||||
pub wait_count: i32,
|
||||
pub cross_list: Option<String>,
|
||||
pub cross_list_capacity: Option<i32>,
|
||||
pub cross_list_count: Option<i32>,
|
||||
pub cross_list_available: Option<i32>,
|
||||
pub credit_hour_high: Option<i32>,
|
||||
pub credit_hour_low: Option<i32>,
|
||||
pub credit_hour_indicator: Option<String>,
|
||||
pub open_section: bool,
|
||||
pub link_identifier: Option<String>,
|
||||
pub is_section_linked: bool,
|
||||
pub subject_course: String,
|
||||
pub reserved_seat_summary: Option<String>,
|
||||
pub instructional_method: String,
|
||||
pub instructional_method_description: String,
|
||||
pub section_attributes: Vec<SectionAttribute>,
|
||||
pub faculty: Vec<FacultyItem>,
|
||||
pub meetings_faculty: Vec<MeetingTimeResponse>,
|
||||
}
|
||||
|
||||
/// Class details (to be implemented)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassDetails {
|
||||
// TODO: Implement based on Banner API response
|
||||
}
|
||||
194
src/banner/models/meetings.rs
Normal file
194
src/banner/models/meetings.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use chrono::{NaiveDate, NaiveTime, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::terms::Term;
|
||||
|
||||
/// Represents a faculty member associated with a course.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FacultyItem {
|
||||
pub banner_id: u32, // e.g. 150161
|
||||
pub category: Option<String>, // zero-padded digits
|
||||
pub class: String, // internal class name
|
||||
pub course_reference_number: u32, // CRN, e.g. 27294
|
||||
pub display_name: String, // "LastName, FirstName"
|
||||
pub email_address: String, // e.g. FirstName.LastName@utsa.edu
|
||||
pub primary_indicator: bool,
|
||||
pub term: Term,
|
||||
}
|
||||
|
||||
/// Meeting time information for a course.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeetingTime {
|
||||
pub start_date: String, // MM/DD/YYYY, e.g. 08/26/2025
|
||||
pub end_date: String, // MM/DD/YYYY, e.g. 08/26/2025
|
||||
pub begin_time: String, // HHMM, e.g. 1000
|
||||
pub end_time: String, // HHMM, e.g. 1100
|
||||
pub category: String, // unknown meaning, e.g. 01, 02, etc.
|
||||
pub class: String, // internal class name, e.g. net.hedtech.banner.general.overall.MeetingTimeDecorator
|
||||
pub monday: bool, // true if the meeting time occurs on Monday
|
||||
pub tuesday: bool, // true if the meeting time occurs on Tuesday
|
||||
pub wednesday: bool, // true if the meeting time occurs on Wednesday
|
||||
pub thursday: bool, // true if the meeting time occurs on Thursday
|
||||
pub friday: bool, // true if the meeting time occurs on Friday
|
||||
pub saturday: bool, // true if the meeting time occurs on Saturday
|
||||
pub sunday: bool, // true if the meeting time occurs on Sunday
|
||||
pub room: String, // e.g. 1.238
|
||||
pub term: Term, // e.g. 202510
|
||||
pub building: String, // e.g. NPB
|
||||
pub building_description: String, // e.g. North Paseo Building
|
||||
pub campus: String, // campus code, e.g. 11
|
||||
pub campus_description: String, // name of campus, e.g. Main Campus
|
||||
pub course_reference_number: String, // CRN, e.g. 27294
|
||||
pub credit_hour_session: f64, // e.g. 3.0
|
||||
pub hours_week: f64, // e.g. 3.0
|
||||
pub meeting_schedule_type: String, // e.g. AFF
|
||||
pub meeting_type: String, // e.g. HB, H2, H1, OS, OA, OH, ID, FF
|
||||
pub meeting_type_description: String,
|
||||
}
|
||||
|
||||
/// API response wrapper for meeting times.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeetingTimesApiResponse {
|
||||
pub fmt: Vec<MeetingTimeResponse>,
|
||||
}
|
||||
|
||||
/// Meeting time response wrapper.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MeetingTimeResponse {
|
||||
pub category: Option<String>,
|
||||
pub class: String,
|
||||
pub course_reference_number: String,
|
||||
pub faculty: Vec<FacultyItem>,
|
||||
pub meeting_time: MeetingTime,
|
||||
pub term: String,
|
||||
}
|
||||
|
||||
impl MeetingTimeResponse {
|
||||
/// Returns a formatted string representation of the meeting time.
|
||||
pub fn to_string(&self) -> String {
|
||||
match self.meeting_time.meeting_type.as_str() {
|
||||
"HB" | "H2" | "H1" => format!("{}\nHybrid {}", self.time_string(), self.place_string()),
|
||||
"OS" => format!("{}\nOnline Only", self.time_string()),
|
||||
"OA" => "No Time\nOnline Asynchronous".to_string(),
|
||||
"OH" => format!("{}\nOnline Partial", self.time_string()),
|
||||
"ID" => "To Be Arranged".to_string(),
|
||||
"FF" => format!("{}\n{}", self.time_string(), self.place_string()),
|
||||
_ => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a formatted string of the meeting times.
|
||||
pub fn time_string(&self) -> String {
|
||||
let start_time = self.parse_time(&self.meeting_time.begin_time);
|
||||
let end_time = self.parse_time(&self.meeting_time.end_time);
|
||||
|
||||
match (start_time, end_time) {
|
||||
(Some(start), Some(end)) => {
|
||||
format!(
|
||||
"{} {}-{}",
|
||||
self.days_string(),
|
||||
format_time(start),
|
||||
format_time(end)
|
||||
)
|
||||
}
|
||||
_ => "???".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a formatted string representing the location of the meeting.
|
||||
pub fn place_string(&self) -> String {
|
||||
if self.meeting_time.room.is_empty() {
|
||||
"Online".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} | {} | {} {}",
|
||||
self.meeting_time.campus_description,
|
||||
self.meeting_time.building_description,
|
||||
self.meeting_time.building,
|
||||
self.meeting_time.room
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a compact string representation of meeting days.
|
||||
pub fn days_string(&self) -> String {
|
||||
let mut days = String::new();
|
||||
if self.meeting_time.monday {
|
||||
days.push('M');
|
||||
}
|
||||
if self.meeting_time.tuesday {
|
||||
days.push_str("Tu");
|
||||
}
|
||||
if self.meeting_time.wednesday {
|
||||
days.push('W');
|
||||
}
|
||||
if self.meeting_time.thursday {
|
||||
days.push_str("Th");
|
||||
}
|
||||
if self.meeting_time.friday {
|
||||
days.push('F');
|
||||
}
|
||||
if self.meeting_time.saturday {
|
||||
days.push_str("Sa");
|
||||
}
|
||||
if self.meeting_time.sunday {
|
||||
days.push_str("Su");
|
||||
}
|
||||
|
||||
if days.is_empty() {
|
||||
"None".to_string()
|
||||
} else if days.len() == 14 {
|
||||
// All days
|
||||
"Everyday".to_string()
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a time string in HHMM format to NaiveTime.
|
||||
fn parse_time(&self, time_str: &str) -> Option<NaiveTime> {
|
||||
if time_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let time_int: u32 = time_str.parse().ok()?;
|
||||
let hours = time_int / 100;
|
||||
let minutes = time_int % 100;
|
||||
|
||||
NaiveTime::from_hms_opt(hours, minutes, 0)
|
||||
}
|
||||
|
||||
/// Parse a date string in MM/DD/YYYY format.
|
||||
pub fn parse_date(date_str: &str) -> Option<NaiveDate> {
|
||||
NaiveDate::parse_from_str(date_str, "%m/%d/%Y").ok()
|
||||
}
|
||||
|
||||
/// Get the start date as NaiveDate.
|
||||
pub fn start_date(&self) -> Option<NaiveDate> {
|
||||
Self::parse_date(&self.meeting_time.start_date)
|
||||
}
|
||||
|
||||
/// Get the end date as NaiveDate.
|
||||
pub fn end_date(&self) -> Option<NaiveDate> {
|
||||
Self::parse_date(&self.meeting_time.end_date)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a NaiveTime in 12-hour format.
|
||||
fn format_time(time: NaiveTime) -> String {
|
||||
let hour = time.hour();
|
||||
let minute = time.minute();
|
||||
|
||||
if hour == 0 {
|
||||
format!("12:{:02}AM", minute)
|
||||
} else if hour < 12 {
|
||||
format!("{}:{:02}AM", hour, minute)
|
||||
} else if hour == 12 {
|
||||
format!("12:{:02}PM", minute)
|
||||
} else {
|
||||
format!("{}:{:02}PM", hour - 12, minute)
|
||||
}
|
||||
}
|
||||
14
src/banner/models/mod.rs
Normal file
14
src/banner/models/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Data models for the Banner API.
|
||||
|
||||
pub mod common;
|
||||
pub mod courses;
|
||||
pub mod meetings;
|
||||
pub mod search;
|
||||
pub mod terms;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use common::*;
|
||||
pub use courses::*;
|
||||
pub use meetings::*;
|
||||
pub use search::*;
|
||||
pub use terms::*;
|
||||
23
src/banner/models/search.rs
Normal file
23
src/banner/models/search.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::courses::Course;
|
||||
|
||||
/// Search result wrapper
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResult {
|
||||
pub success: bool,
|
||||
pub total_count: i32,
|
||||
pub page_offset: i32,
|
||||
pub page_max_size: i32,
|
||||
pub path_mode: String,
|
||||
pub search_results_config: Vec<SearchResultConfig>,
|
||||
pub data: Vec<Course>,
|
||||
}
|
||||
|
||||
/// Search result configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResultConfig {
|
||||
pub config: String,
|
||||
pub display: String,
|
||||
}
|
||||
76
src/banner/models/terms.rs
Normal file
76
src/banner/models/terms.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use std::{ops::RangeInclusive, str::FromStr};
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Represents a term in the Banner system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Term {
|
||||
pub year: u32, // 2024, 2025, etc
|
||||
pub season: Season,
|
||||
}
|
||||
|
||||
/// Represents a season within a term
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Season {
|
||||
Fall,
|
||||
Spring,
|
||||
Summer,
|
||||
}
|
||||
|
||||
impl Term {
|
||||
pub fn to_string(&self) -> String {
|
||||
format!("{}{}", self.year, self.season.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Season {
|
||||
pub fn to_string(&self) -> String {
|
||||
(match self {
|
||||
Season::Fall => "10",
|
||||
Season::Spring => "20",
|
||||
Season::Summer => "30",
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn from_string(s: &str) -> Option<Season> {
|
||||
match s {
|
||||
"10" => Some(Season::Fall),
|
||||
"20" => Some(Season::Spring),
|
||||
"30" => Some(Season::Summer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CURRENT_YEAR: u32 = compile_time::date!().year() as u32;
|
||||
const VALID_YEARS: RangeInclusive<u32> = 2007..=(CURRENT_YEAR + 10);
|
||||
|
||||
impl FromStr for Term {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if s.len() != 6 {
|
||||
return Err(anyhow::anyhow!("Term string must be 6 characters"));
|
||||
}
|
||||
|
||||
let year = s[0..4].parse::<u32>().context("Failed to parse year")?;
|
||||
if !VALID_YEARS.contains(&year) {
|
||||
return Err(anyhow::anyhow!("Year out of range"));
|
||||
}
|
||||
|
||||
let season =
|
||||
Season::from_string(&s[4..6]).ok_or_else(|| anyhow::anyhow!("Invalid season code"))?;
|
||||
|
||||
Ok(Term { year, season })
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Season {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::from_string(s).ok_or(())
|
||||
}
|
||||
}
|
||||
313
src/banner/query.rs
Normal file
313
src/banner/query.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! Query builder for Banner API course searches.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Range of two integers
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Range {
|
||||
pub low: i32,
|
||||
pub high: i32,
|
||||
}
|
||||
|
||||
/// Builder for constructing Banner API search queries
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SearchQuery {
|
||||
subject: Option<String>,
|
||||
title: Option<String>,
|
||||
keywords: Option<Vec<String>>,
|
||||
open_only: Option<bool>,
|
||||
term_part: Option<Vec<String>>,
|
||||
campus: Option<Vec<String>>,
|
||||
instructional_method: Option<Vec<String>>,
|
||||
attributes: Option<Vec<String>>,
|
||||
instructor: Option<Vec<u64>>,
|
||||
start_time: Option<Duration>,
|
||||
end_time: Option<Duration>,
|
||||
min_credits: Option<i32>,
|
||||
max_credits: Option<i32>,
|
||||
offset: i32,
|
||||
max_results: i32,
|
||||
course_number_range: Option<Range>,
|
||||
}
|
||||
|
||||
impl SearchQuery {
|
||||
/// Creates a new SearchQuery with default values
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_results: 8,
|
||||
offset: 0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the subject for the query
|
||||
pub fn subject<S: Into<String>>(mut self, subject: S) -> Self {
|
||||
self.subject = Some(subject.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the title for the query
|
||||
pub fn title<S: Into<String>>(mut self, title: S) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the keywords for the query
|
||||
pub fn keywords(mut self, keywords: Vec<String>) -> Self {
|
||||
self.keywords = Some(keywords);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a keyword to the query
|
||||
pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self {
|
||||
match &mut self.keywords {
|
||||
Some(keywords) => keywords.push(keyword.into()),
|
||||
None => self.keywords = Some(vec![keyword.into()]),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether to search for open courses only
|
||||
pub fn open_only(mut self, open_only: bool) -> Self {
|
||||
self.open_only = Some(open_only);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the term part for the query
|
||||
pub fn term_part(mut self, term_part: Vec<String>) -> Self {
|
||||
self.term_part = Some(term_part);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the campuses for the query
|
||||
pub fn campus(mut self, campus: Vec<String>) -> Self {
|
||||
self.campus = Some(campus);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the instructional methods for the query
|
||||
pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self {
|
||||
self.instructional_method = Some(instructional_method);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the attributes for the query
|
||||
pub fn attributes(mut self, attributes: Vec<String>) -> Self {
|
||||
self.attributes = Some(attributes);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the instructors for the query
|
||||
pub fn instructor(mut self, instructor: Vec<u64>) -> Self {
|
||||
self.instructor = Some(instructor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the start time for the query
|
||||
pub fn start_time(mut self, start_time: Duration) -> Self {
|
||||
self.start_time = Some(start_time);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the end time for the query
|
||||
pub fn end_time(mut self, end_time: Duration) -> Self {
|
||||
self.end_time = Some(end_time);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the credit range for the query
|
||||
pub fn credits(mut self, low: i32, high: i32) -> Self {
|
||||
self.min_credits = Some(low);
|
||||
self.max_credits = Some(high);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the minimum credits for the query
|
||||
pub fn min_credits(mut self, value: i32) -> Self {
|
||||
self.min_credits = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum credits for the query
|
||||
pub fn max_credits(mut self, value: i32) -> Self {
|
||||
self.max_credits = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the course number range for the query
|
||||
pub fn course_numbers(mut self, low: i32, high: i32) -> Self {
|
||||
self.course_number_range = Some(Range { low, high });
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the offset for pagination
|
||||
pub fn offset(mut self, offset: i32) -> Self {
|
||||
self.offset = offset;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum number of results to return
|
||||
pub fn max_results(mut self, max_results: i32) -> Self {
|
||||
self.max_results = max_results;
|
||||
self
|
||||
}
|
||||
|
||||
/// Converts the query into URL parameters for the Banner API
|
||||
pub fn to_params(&self) -> HashMap<String, String> {
|
||||
let mut params = HashMap::new();
|
||||
|
||||
if let Some(ref subject) = self.subject {
|
||||
params.insert("txt_subject".to_string(), subject.clone());
|
||||
}
|
||||
|
||||
if let Some(ref title) = self.title {
|
||||
params.insert("txt_courseTitle".to_string(), title.trim().to_string());
|
||||
}
|
||||
|
||||
if let Some(ref keywords) = self.keywords {
|
||||
params.insert("txt_keywordlike".to_string(), keywords.join(" "));
|
||||
}
|
||||
|
||||
if self.open_only.is_some() {
|
||||
params.insert("chk_open_only".to_string(), "true".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref term_part) = self.term_part {
|
||||
params.insert("txt_partOfTerm".to_string(), term_part.join(","));
|
||||
}
|
||||
|
||||
if let Some(ref campus) = self.campus {
|
||||
params.insert("txt_campus".to_string(), campus.join(","));
|
||||
}
|
||||
|
||||
if let Some(ref attributes) = self.attributes {
|
||||
params.insert("txt_attribute".to_string(), attributes.join(","));
|
||||
}
|
||||
|
||||
if let Some(ref instructor) = self.instructor {
|
||||
let instructor_str = instructor
|
||||
.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
params.insert("txt_instructor".to_string(), instructor_str);
|
||||
}
|
||||
|
||||
if let Some(start_time) = self.start_time {
|
||||
let (hour, minute, meridiem) = format_time_parameter(start_time);
|
||||
params.insert("select_start_hour".to_string(), hour);
|
||||
params.insert("select_start_min".to_string(), minute);
|
||||
params.insert("select_start_ampm".to_string(), meridiem);
|
||||
}
|
||||
|
||||
if let Some(end_time) = self.end_time {
|
||||
let (hour, minute, meridiem) = format_time_parameter(end_time);
|
||||
params.insert("select_end_hour".to_string(), hour);
|
||||
params.insert("select_end_min".to_string(), minute);
|
||||
params.insert("select_end_ampm".to_string(), meridiem);
|
||||
}
|
||||
|
||||
if let Some(min_credits) = self.min_credits {
|
||||
params.insert("txt_credithourlow".to_string(), min_credits.to_string());
|
||||
}
|
||||
|
||||
if let Some(max_credits) = self.max_credits {
|
||||
params.insert("txt_credithourhigh".to_string(), max_credits.to_string());
|
||||
}
|
||||
|
||||
if let Some(ref range) = self.course_number_range {
|
||||
params.insert("txt_course_number_range".to_string(), range.low.to_string());
|
||||
params.insert(
|
||||
"txt_course_number_range_to".to_string(),
|
||||
range.high.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
params.insert("pageOffset".to_string(), self.offset.to_string());
|
||||
params.insert("pageMaxSize".to_string(), self.max_results.to_string());
|
||||
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a Duration into hour, minute, and meridiem strings for Banner API
|
||||
fn format_time_parameter(duration: Duration) -> (String, String, String) {
|
||||
let total_minutes = duration.as_secs() / 60;
|
||||
let hours = total_minutes / 60;
|
||||
let minutes = total_minutes % 60;
|
||||
|
||||
let minute_str = minutes.to_string();
|
||||
|
||||
if hours >= 12 {
|
||||
let meridiem = "PM".to_string();
|
||||
let hour_str = if hours >= 13 {
|
||||
(hours - 12).to_string()
|
||||
} else {
|
||||
hours.to_string()
|
||||
};
|
||||
(hour_str, minute_str, meridiem)
|
||||
} else {
|
||||
let meridiem = "AM".to_string();
|
||||
let hour_str = hours.to_string();
|
||||
(hour_str, minute_str, meridiem)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SearchQuery {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(ref subject) = self.subject {
|
||||
parts.push(format!("subject={}", subject));
|
||||
}
|
||||
if let Some(ref title) = self.title {
|
||||
parts.push(format!("title={}", title.trim()));
|
||||
}
|
||||
if let Some(ref keywords) = self.keywords {
|
||||
parts.push(format!("keywords={}", keywords.join(" ")));
|
||||
}
|
||||
if self.open_only.is_some() {
|
||||
parts.push("openOnly=true".to_string());
|
||||
}
|
||||
if let Some(ref term_part) = self.term_part {
|
||||
parts.push(format!("termPart={}", term_part.join(",")));
|
||||
}
|
||||
if let Some(ref campus) = self.campus {
|
||||
parts.push(format!("campus={}", campus.join(",")));
|
||||
}
|
||||
if let Some(ref attributes) = self.attributes {
|
||||
parts.push(format!("attributes={}", attributes.join(",")));
|
||||
}
|
||||
if let Some(ref instructor) = self.instructor {
|
||||
let instructor_str = instructor
|
||||
.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
parts.push(format!("instructor={}", instructor_str));
|
||||
}
|
||||
if let Some(start_time) = self.start_time {
|
||||
let (hour, minute, meridiem) = format_time_parameter(start_time);
|
||||
parts.push(format!("startTime={}:{}:{}", hour, minute, meridiem));
|
||||
}
|
||||
if let Some(end_time) = self.end_time {
|
||||
let (hour, minute, meridiem) = format_time_parameter(end_time);
|
||||
parts.push(format!("endTime={}:{}:{}", hour, minute, meridiem));
|
||||
}
|
||||
if let Some(min_credits) = self.min_credits {
|
||||
parts.push(format!("minCredits={}", min_credits));
|
||||
}
|
||||
if let Some(max_credits) = self.max_credits {
|
||||
parts.push(format!("maxCredits={}", max_credits));
|
||||
}
|
||||
if let Some(ref range) = self.course_number_range {
|
||||
parts.push(format!("courseNumberRange={}-{}", range.low, range.high));
|
||||
}
|
||||
|
||||
parts.push(format!("offset={}", self.offset));
|
||||
parts.push(format!("maxResults={}", self.max_results));
|
||||
|
||||
write!(f, "{}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
290
src/banner/scraper.rs
Normal file
290
src/banner/scraper.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! Course scraping functionality for the Banner API.
|
||||
|
||||
use crate::banner::{api::BannerApi, models::*, query::SearchQuery};
|
||||
use anyhow::{Context, Result};
|
||||
use redis::AsyncCommands;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Priority majors that should be scraped more frequently
|
||||
const PRIORITY_MAJORS: &[&str] = &["CS", "CPE", "MAT", "EE", "IS"];
|
||||
|
||||
/// Maximum number of courses to fetch per page
|
||||
const MAX_PAGE_SIZE: i32 = 500;
|
||||
|
||||
/// Course scraper for Banner API
|
||||
pub struct CourseScraper {
|
||||
api: BannerApi,
|
||||
redis_client: redis::Client,
|
||||
}
|
||||
|
||||
impl CourseScraper {
|
||||
/// Creates a new course scraper
|
||||
pub fn new(api: BannerApi, redis_url: &str) -> Result<Self> {
|
||||
let redis_client =
|
||||
redis::Client::open(redis_url).context("Failed to create Redis client")?;
|
||||
|
||||
Ok(Self { api, redis_client })
|
||||
}
|
||||
|
||||
/// Scrapes all courses and stores them in Redis
|
||||
pub async fn scrape_all(&self, term: &str) -> Result<()> {
|
||||
// Get all subjects
|
||||
let subjects = self
|
||||
.api
|
||||
.get_subjects("", term, 1, 100)
|
||||
.await
|
||||
.context("Failed to get subjects for scraping")?;
|
||||
|
||||
if subjects.is_empty() {
|
||||
return Err(anyhow::anyhow!("No subjects found for term {}", term));
|
||||
}
|
||||
|
||||
// Categorize subjects
|
||||
let (priority_subjects, other_subjects): (Vec<_>, Vec<_>) = subjects
|
||||
.into_iter()
|
||||
.partition(|subject| PRIORITY_MAJORS.contains(&subject.code.as_str()));
|
||||
|
||||
// Get expired subjects that need scraping
|
||||
let mut expired_subjects = Vec::new();
|
||||
expired_subjects.extend(self.get_expired_subjects(&priority_subjects, term).await?);
|
||||
expired_subjects.extend(self.get_expired_subjects(&other_subjects, term).await?);
|
||||
|
||||
if expired_subjects.is_empty() {
|
||||
info!("No expired subjects found, skipping scrape");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"Scraping {} subjects for term {}",
|
||||
expired_subjects.len(),
|
||||
term
|
||||
);
|
||||
|
||||
// Scrape each expired subject
|
||||
for subject in expired_subjects {
|
||||
if let Err(e) = self.scrape_subject(&subject.code, term).await {
|
||||
error!("Failed to scrape subject {}: {}", subject.code, e);
|
||||
}
|
||||
|
||||
// Rate limiting between subjects
|
||||
time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets subjects that have expired and need to be scraped
|
||||
async fn get_expired_subjects(&self, subjects: &[Pair], term: &str) -> Result<Vec<Pair>> {
|
||||
let mut conn = self
|
||||
.redis_client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.context("Failed to get Redis connection")?;
|
||||
|
||||
let mut expired = Vec::new();
|
||||
|
||||
for subject in subjects {
|
||||
let key = format!("scraped:{}:{}", subject.code, term);
|
||||
let scraped: Option<String> = conn
|
||||
.get(&key)
|
||||
.await
|
||||
.context("Failed to check scrape status in Redis")?;
|
||||
|
||||
// If not scraped or marked as expired (empty/0), add to list
|
||||
if scraped.is_none() || scraped.as_deref() == Some("0") {
|
||||
expired.push(subject.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(expired)
|
||||
}
|
||||
|
||||
/// Scrapes all courses for a specific subject
|
||||
pub async fn scrape_subject(&self, subject: &str, term: &str) -> Result<()> {
|
||||
let mut offset = 0;
|
||||
let mut total_courses = 0;
|
||||
|
||||
loop {
|
||||
let query = SearchQuery::new()
|
||||
.subject(subject)
|
||||
.offset(offset)
|
||||
.max_results(MAX_PAGE_SIZE * 2);
|
||||
|
||||
let result = self
|
||||
.api
|
||||
.search(term, &query, "subjectDescription", false)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to search for subject {} at offset {}",
|
||||
subject, offset
|
||||
)
|
||||
})?;
|
||||
|
||||
if !result.success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Search marked unsuccessful for subject {}",
|
||||
subject
|
||||
));
|
||||
}
|
||||
|
||||
let course_count = result.data.len() as i32;
|
||||
total_courses += course_count;
|
||||
|
||||
debug!(
|
||||
"Retrieved {} courses for subject {} at offset {}",
|
||||
course_count, subject, offset
|
||||
);
|
||||
|
||||
// Store each course in Redis
|
||||
for course in result.data {
|
||||
if let Err(e) = self.store_course(&course).await {
|
||||
error!(
|
||||
"Failed to store course {}: {}",
|
||||
course.course_reference_number, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we got a full page and should continue
|
||||
if course_count >= MAX_PAGE_SIZE {
|
||||
if course_count > MAX_PAGE_SIZE {
|
||||
warn!(
|
||||
"Course count {} exceeds max page size {}",
|
||||
course_count, MAX_PAGE_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
offset += MAX_PAGE_SIZE;
|
||||
debug!(
|
||||
"Continuing to next page for subject {} at offset {}",
|
||||
subject, offset
|
||||
);
|
||||
|
||||
// Rate limiting between pages
|
||||
time::sleep(Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Scraped {} total courses for subject {}",
|
||||
total_courses, subject
|
||||
);
|
||||
|
||||
// Mark subject as scraped with expiry
|
||||
self.mark_subject_scraped(subject, term, total_courses)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stores a course in Redis
|
||||
async fn store_course(&self, course: &Course) -> Result<()> {
|
||||
let mut conn = self
|
||||
.redis_client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.context("Failed to get Redis connection")?;
|
||||
|
||||
let key = format!("class:{}", course.course_reference_number);
|
||||
let serialized = serde_json::to_string(course).context("Failed to serialize course")?;
|
||||
|
||||
let _: () = conn
|
||||
.set(&key, serialized)
|
||||
.await
|
||||
.context("Failed to store course in Redis")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks a subject as scraped with appropriate expiry time
|
||||
async fn mark_subject_scraped(
|
||||
&self,
|
||||
subject: &str,
|
||||
term: &str,
|
||||
course_count: i32,
|
||||
) -> Result<()> {
|
||||
let mut conn = self
|
||||
.redis_client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.context("Failed to get Redis connection")?;
|
||||
|
||||
let key = format!("scraped:{}:{}", subject, term);
|
||||
let expiry = self.calculate_expiry(subject, course_count);
|
||||
|
||||
let value = if course_count == 0 { -1 } else { course_count };
|
||||
|
||||
let _: () = conn
|
||||
.set_ex(&key, value, expiry.as_secs() as u64)
|
||||
.await
|
||||
.context("Failed to mark subject as scraped")?;
|
||||
|
||||
debug!(
|
||||
"Marked subject {} as scraped with {} courses, expiry: {:?}",
|
||||
subject, course_count, expiry
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculates expiry time for a scraped subject based on various factors
|
||||
fn calculate_expiry(&self, subject: &str, course_count: i32) -> Duration {
|
||||
// Base calculation: 1 hour per 100 courses
|
||||
let mut base_expiry = Duration::from_secs(3600 * (course_count as u64 / 100).max(1));
|
||||
|
||||
// Special handling for subjects with few courses
|
||||
if course_count < 50 {
|
||||
// Linear interpolation: 1 course = 12 hours, 49 courses = 1 hour
|
||||
let hours = 12.0 - ((course_count as f64 - 1.0) / 48.0) * 11.0;
|
||||
base_expiry = Duration::from_secs((hours * 3600.0) as u64);
|
||||
}
|
||||
|
||||
// Priority subjects get shorter expiry (more frequent updates)
|
||||
if PRIORITY_MAJORS.contains(&subject) {
|
||||
base_expiry = base_expiry / 3;
|
||||
}
|
||||
|
||||
// Add random variance (±15%)
|
||||
let variance = (base_expiry.as_secs() as f64 * 0.15) as u64;
|
||||
let random_offset = (rand::random::<f64>() - 0.5) * 2.0 * variance as f64;
|
||||
|
||||
let final_expiry = if random_offset > 0.0 {
|
||||
base_expiry + Duration::from_secs(random_offset as u64)
|
||||
} else {
|
||||
base_expiry.saturating_sub(Duration::from_secs((-random_offset) as u64))
|
||||
};
|
||||
|
||||
// Ensure minimum of 1 hour
|
||||
final_expiry.max(Duration::from_secs(3600))
|
||||
}
|
||||
|
||||
/// Gets a course from Redis cache
|
||||
pub async fn get_course(&self, crn: &str) -> Result<Option<Course>> {
|
||||
let mut conn = self
|
||||
.redis_client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.context("Failed to get Redis connection")?;
|
||||
|
||||
let key = format!("class:{}", crn);
|
||||
let serialized: Option<String> = conn
|
||||
.get(&key)
|
||||
.await
|
||||
.context("Failed to get course from Redis")?;
|
||||
|
||||
match serialized {
|
||||
Some(data) => {
|
||||
let course: Course = serde_json::from_str(&data)
|
||||
.context("Failed to deserialize course from Redis")?;
|
||||
Ok(Some(course))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/banner/session.rs
Normal file
190
src/banner/session.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Session management for Banner API.
|
||||
|
||||
use anyhow::Result;
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use reqwest::Client;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Session manager for Banner API interactions
|
||||
#[derive(Debug)]
|
||||
pub struct SessionManager {
|
||||
current_session: Mutex<Option<SessionData>>,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionData {
|
||||
session_id: String,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
const SESSION_EXPIRY: Duration = Duration::from_secs(25 * 60); // 25 minutes
|
||||
|
||||
/// Creates a new session manager
|
||||
pub fn new(base_url: String, client: Client) -> Self {
|
||||
Self {
|
||||
current_session: Mutex::new(None),
|
||||
base_url,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures a valid session is available, creating one if necessary
|
||||
pub fn ensure_session(&self) -> Result<String> {
|
||||
let mut session_guard = self.current_session.lock().unwrap();
|
||||
|
||||
if let Some(ref session) = *session_guard {
|
||||
if session.created_at.elapsed() < Self::SESSION_EXPIRY {
|
||||
return Ok(session.session_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new session
|
||||
let session_id = self.generate_session_id();
|
||||
*session_guard = Some(SessionData {
|
||||
session_id: session_id.clone(),
|
||||
created_at: Instant::now(),
|
||||
});
|
||||
|
||||
debug!("Generated new Banner session: {}", session_id);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Generates a new session ID mimicking Banner's format
|
||||
fn generate_session_id(&self) -> String {
|
||||
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), 5);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
format!("{}{}", random_part, timestamp)
|
||||
}
|
||||
|
||||
/// Sets up initial session cookies by making required Banner API requests
|
||||
pub async fn setup(&self) -> Result<()> {
|
||||
info!("Setting up Banner session...");
|
||||
|
||||
let request_paths = ["/registration/registration", "/selfServiceMenu/data"];
|
||||
|
||||
for path in &request_paths {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[("_", timestamp_nonce())])
|
||||
.header("User-Agent", user_agent())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to setup session, request to {} returned {}",
|
||||
path,
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Cookie validation would require additional setup in a real implementation
|
||||
debug!("Session setup complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Selects a term for the current session
|
||||
pub async fn select_term(&self, term: &str) -> Result<()> {
|
||||
let session_id = self.ensure_session()?;
|
||||
|
||||
let form_data = [
|
||||
("term", term),
|
||||
("studyPath", ""),
|
||||
("studyPathText", ""),
|
||||
("startDatepicker", ""),
|
||||
("endDatepicker", ""),
|
||||
("uniqueSessionId", &session_id),
|
||||
];
|
||||
|
||||
let url = format!("{}/term/search", self.base_url);
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.query(&[("mode", "search")])
|
||||
.form(&form_data)
|
||||
.header("User-Agent", user_agent())
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to select term {}: {}",
|
||||
term,
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RedirectResponse {
|
||||
#[serde(rename = "fwdUrl")]
|
||||
fwd_url: String,
|
||||
}
|
||||
|
||||
let redirect: RedirectResponse = response.json().await?;
|
||||
|
||||
// Follow the redirect
|
||||
let redirect_url = format!("{}{}", self.base_url, redirect.fwd_url);
|
||||
let redirect_response = self
|
||||
.client
|
||||
.get(&redirect_url)
|
||||
.header("User-Agent", user_agent())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !redirect_response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to follow redirect: {}",
|
||||
redirect_response.status()
|
||||
));
|
||||
}
|
||||
|
||||
debug!("Successfully selected term: {}", term);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the data form (required before new searches)
|
||||
pub async fn reset_data_form(&self) -> Result<()> {
|
||||
let url = format!("{}/classSearch/resetDataForm", self.base_url);
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("User-Agent", user_agent())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to reset data form: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a timestamp-based nonce
|
||||
fn timestamp_nonce() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Returns a browser-like user agent string
|
||||
fn user_agent() -> &'static str {
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
|
||||
}
|
||||
323
src/bot/commands/gcal.rs
Normal file
323
src/bot/commands/gcal.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
//! Google Calendar command implementation.
|
||||
|
||||
use crate::banner::{Course, MeetingTime, MeetingTimeResponse};
|
||||
use crate::bot::{Context, Error};
|
||||
use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Timelike, Utc};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
use url::Url;
|
||||
|
||||
/// Generate a link to create a Google Calendar event for a course
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn gcal(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Course Reference Number (CRN)"] crn: i32,
|
||||
) -> Result<(), Error> {
|
||||
let user = ctx.author();
|
||||
info!(source = user.name, target = crn, "gcal command invoked");
|
||||
|
||||
ctx.defer().await?;
|
||||
|
||||
let app_state = &ctx.data().app_state;
|
||||
let banner_api = &app_state.banner_api;
|
||||
|
||||
// TODO: Get current term dynamically
|
||||
let term = 202610; // Hardcoded for now
|
||||
|
||||
// TODO: Replace with actual course data when BannerApi::get_course is implemented
|
||||
let course = Course {
|
||||
id: 0,
|
||||
term: term.to_string(),
|
||||
term_desc: "Fall 2026".to_string(),
|
||||
course_reference_number: crn.to_string(),
|
||||
part_of_term: "1".to_string(),
|
||||
course_number: "0000".to_string(),
|
||||
subject: "CS".to_string(),
|
||||
subject_description: "Computer Science".to_string(),
|
||||
sequence_number: "001".to_string(),
|
||||
campus_description: "Main Campus".to_string(),
|
||||
schedule_type_description: "Lecture".to_string(),
|
||||
course_title: "Example Course".to_string(),
|
||||
credit_hours: 3,
|
||||
maximum_enrollment: 30,
|
||||
enrollment: 25,
|
||||
seats_available: 5,
|
||||
wait_capacity: 10,
|
||||
wait_count: 0,
|
||||
cross_list: None,
|
||||
cross_list_capacity: None,
|
||||
cross_list_count: None,
|
||||
cross_list_available: None,
|
||||
credit_hour_high: None,
|
||||
credit_hour_low: None,
|
||||
credit_hour_indicator: None,
|
||||
open_section: true,
|
||||
link_identifier: None,
|
||||
is_section_linked: false,
|
||||
subject_course: "CS0000".to_string(),
|
||||
reserved_seat_summary: None,
|
||||
instructional_method: "FF".to_string(),
|
||||
instructional_method_description: "Face to Face".to_string(),
|
||||
section_attributes: vec![],
|
||||
faculty: vec![],
|
||||
meetings_faculty: vec![],
|
||||
};
|
||||
|
||||
// Get meeting times
|
||||
let meeting_times = banner_api.get_course_meeting_time(term, crn).await?;
|
||||
|
||||
if meeting_times.is_empty() {
|
||||
ctx.say("No meeting times found for this course").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Find a meeting time that actually meets (not ID or OA types)
|
||||
let meeting_time = meeting_times
|
||||
.iter()
|
||||
.find(|mt| !matches!(mt.meeting_time.meeting_type.as_str(), "ID" | "OA"))
|
||||
.ok_or("Course does not meet at a defined moment in time")?;
|
||||
|
||||
// Generate the Google Calendar URL
|
||||
match generate_gcal_url(&course, meeting_time) {
|
||||
Ok(calendar_url) => {
|
||||
ctx.say(format!("[Add to Google Calendar](<{}>)", calendar_url))
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to generate Google Calendar URL: {}", e);
|
||||
ctx.say(format!("Error generating calendar link: {}", e))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
info!("gcal command completed for CRN: {}", crn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate Google Calendar URL for a course
|
||||
fn generate_gcal_url(course: &Course, meeting_time: &MeetingTimeResponse) -> Result<String, Error> {
|
||||
// Get start and end dates
|
||||
let start_date = meeting_time
|
||||
.start_date()
|
||||
.ok_or("Could not parse start date")?;
|
||||
let end_date = meeting_time.end_date().ok_or("Could not parse end date")?;
|
||||
|
||||
// Get start and end times - parse from the time string
|
||||
let time_str = meeting_time.time_string();
|
||||
let time_parts: Vec<&str> = time_str.split(' ').collect();
|
||||
|
||||
if time_parts.len() < 2 {
|
||||
return Err(format!(
|
||||
"Invalid time format: expected at least 2 parts, got {} parts. Time string: '{}'",
|
||||
time_parts.len(),
|
||||
time_str
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let time_range = time_parts[1];
|
||||
let times: Vec<&str> = time_range.split('-').collect();
|
||||
|
||||
if times.len() != 2 {
|
||||
return Err(format!(
|
||||
"Invalid time range format: expected 2 parts, got {} parts. Time range: '{}'",
|
||||
times.len(),
|
||||
time_range
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Create timestamps in UTC (assuming Central time)
|
||||
let central_tz = chrono_tz::US::Central;
|
||||
|
||||
let dt_start = central_tz
|
||||
.with_ymd_and_hms(
|
||||
start_date.year(),
|
||||
start_date.month(),
|
||||
start_date.day(),
|
||||
start_time.hour(),
|
||||
start_time.minute(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let dt_end = central_tz
|
||||
.with_ymd_and_hms(
|
||||
end_date.year(),
|
||||
end_date.month(),
|
||||
end_date.day(),
|
||||
end_time.hour(),
|
||||
end_time.minute(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
|
||||
// Format times in UTC for Google Calendar
|
||||
let start_str = dt_start.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let end_str = dt_end.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
// Generate RRULE for recurrence
|
||||
let rrule = generate_rrule(meeting_time, end_date);
|
||||
|
||||
// Build calendar URL
|
||||
let mut params = HashMap::new();
|
||||
|
||||
let course_text = format!(
|
||||
"{} {} - {}",
|
||||
course.subject, course.course_number, course.course_title
|
||||
);
|
||||
let dates_text = format!("{}/{}", start_str, end_str);
|
||||
|
||||
// Get instructor name
|
||||
let instructor_name = if !course.faculty.is_empty() {
|
||||
&course.faculty[0].display_name
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
|
||||
let days_text = weekdays_to_string(&meeting_time.meeting_time);
|
||||
let details_text = format!(
|
||||
"CRN: {}\nInstructor: {}\nDays: {}",
|
||||
course.course_reference_number, instructor_name, days_text
|
||||
);
|
||||
|
||||
let location_text = meeting_time.place_string();
|
||||
let recur_text = format!("RRULE:{}", rrule);
|
||||
|
||||
params.insert("action", "TEMPLATE");
|
||||
params.insert("text", &course_text);
|
||||
params.insert("dates", &dates_text);
|
||||
params.insert("details", &details_text);
|
||||
params.insert("location", &location_text);
|
||||
params.insert("trp", "true");
|
||||
params.insert("ctz", "America/Chicago");
|
||||
params.insert("recur", &recur_text);
|
||||
|
||||
// Build URL
|
||||
let mut url = Url::parse("https://calendar.google.com/calendar/render")?;
|
||||
for (key, value) in params {
|
||||
url.query_pairs_mut().append_pair(key, value);
|
||||
}
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Generate RRULE for recurrence
|
||||
fn generate_rrule(meeting_time: &MeetingTimeResponse, end_date: NaiveDate) -> String {
|
||||
let by_day = meeting_time.days_string();
|
||||
|
||||
// Handle edge cases where days_string might return "None" or empty
|
||||
let by_day = if by_day.is_empty() || by_day == "None" {
|
||||
"MO".to_string() // Default to Monday
|
||||
} else {
|
||||
// Convert our day format to Google Calendar format
|
||||
by_day
|
||||
.replace("M", "MO")
|
||||
.replace("Tu", "TU")
|
||||
.replace("W", "WE")
|
||||
.replace("Th", "TH")
|
||||
.replace("F", "FR")
|
||||
.replace("Sa", "SA")
|
||||
.replace("Su", "SU")
|
||||
};
|
||||
|
||||
// Format end date for RRULE (YYYYMMDD format)
|
||||
let until = end_date.format("%Y%m%d").to_string();
|
||||
|
||||
// Build the RRULE string manually to avoid formatting issues
|
||||
let mut rrule = String::new();
|
||||
rrule.push_str("FREQ=WEEKLY;BYDAY=");
|
||||
rrule.push_str(&by_day);
|
||||
rrule.push_str(";UNTIL=");
|
||||
rrule.push_str(&until);
|
||||
|
||||
rrule
|
||||
}
|
||||
|
||||
/// Parse time from formatted string (e.g., "8:00AM", "12:30PM")
|
||||
fn parse_time_from_formatted(time_str: &str) -> Result<NaiveTime, Error> {
|
||||
let time_str = time_str.trim();
|
||||
|
||||
// Handle 12-hour format: "8:00AM", "12:30PM", etc.
|
||||
if time_str.ends_with("AM") || time_str.ends_with("PM") {
|
||||
let (time_part, ampm) = time_str.split_at(time_str.len() - 2);
|
||||
let parts: Vec<&str> = time_part.split(':').collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid time format".into());
|
||||
}
|
||||
|
||||
let hour: u32 = parts[0].parse()?;
|
||||
let minute: u32 = parts[1].parse()?;
|
||||
|
||||
let adjusted_hour = match ampm {
|
||||
"AM" => {
|
||||
if hour == 12 {
|
||||
0
|
||||
} else {
|
||||
hour
|
||||
}
|
||||
}
|
||||
"PM" => {
|
||||
if hour == 12 {
|
||||
12
|
||||
} else {
|
||||
hour + 12
|
||||
}
|
||||
}
|
||||
_ => return Err("Invalid AM/PM indicator".into()),
|
||||
};
|
||||
|
||||
chrono::NaiveTime::from_hms_opt(adjusted_hour, minute, 0).ok_or("Invalid time".into())
|
||||
} else {
|
||||
// Handle 24-hour format: "08:00", "13:30"
|
||||
let parts: Vec<&str> = time_str.split(':').collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return Err("Invalid time format".into());
|
||||
}
|
||||
|
||||
let hour: u32 = parts[0].parse()?;
|
||||
let minute: u32 = parts[1].parse()?;
|
||||
|
||||
NaiveTime::from_hms_opt(hour, minute, 0).ok_or("Invalid time".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert weekdays to string representation
|
||||
fn weekdays_to_string(meeting_time: &MeetingTime) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
if meeting_time.monday {
|
||||
result.push_str("M");
|
||||
}
|
||||
if meeting_time.tuesday {
|
||||
result.push_str("Tu");
|
||||
}
|
||||
if meeting_time.wednesday {
|
||||
result.push_str("W");
|
||||
}
|
||||
if meeting_time.thursday {
|
||||
result.push_str("Th");
|
||||
}
|
||||
if meeting_time.friday {
|
||||
result.push_str("F");
|
||||
}
|
||||
if meeting_time.saturday {
|
||||
result.push_str("Sa");
|
||||
}
|
||||
if meeting_time.sunday {
|
||||
result.push_str("Su");
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
"None".to_string()
|
||||
} else if result.len() == 14 {
|
||||
// All days
|
||||
"Everyday".to_string()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
25
src/bot/commands/ics.rs
Normal file
25
src/bot/commands/ics.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! ICS command implementation for generating calendar files.
|
||||
|
||||
use crate::bot::{Context, Error};
|
||||
|
||||
/// Generate an ICS file for a course
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn ics(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Course Reference Number (CRN)"] crn: i32,
|
||||
) -> Result<(), Error> {
|
||||
ctx.defer().await?;
|
||||
|
||||
// TODO: Get BannerApi from context or global state
|
||||
// TODO: Get current term dynamically
|
||||
let term = 202510; // Hardcoded for now
|
||||
|
||||
// TODO: Implement actual ICS file generation
|
||||
ctx.say(format!(
|
||||
"ICS command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}",
|
||||
crn, term
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
13
src/bot/commands/mod.rs
Normal file
13
src/bot/commands/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! Bot commands module.
|
||||
|
||||
pub mod search;
|
||||
pub mod terms;
|
||||
pub mod time;
|
||||
pub mod ics;
|
||||
pub mod gcal;
|
||||
|
||||
pub use search::search;
|
||||
pub use terms::terms;
|
||||
pub use time::time;
|
||||
pub use ics::ics;
|
||||
pub use gcal::gcal;
|
||||
115
src/bot/commands/search.rs
Normal file
115
src/bot/commands/search.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! Course search command implementation.
|
||||
|
||||
use crate::banner::SearchQuery;
|
||||
use crate::bot::{Context, Error};
|
||||
use regex::Regex;
|
||||
|
||||
/// Search for courses with various filters
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn search(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Course title (exact, use autocomplete)"] title: Option<String>,
|
||||
#[description = "Course code (e.g. 3743, 3000-3999, 3xxx, 3000-)"] code: Option<String>,
|
||||
#[description = "Maximum number of results"] max: Option<i32>,
|
||||
#[description = "Keywords in title or description (space separated)"] keywords: Option<String>,
|
||||
#[description = "Instructor name"] instructor: Option<String>,
|
||||
#[description = "Subject (e.g. Computer Science/CS, Mathematics/MAT)"] subject: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
// Defer the response since this might take a while
|
||||
ctx.defer().await?;
|
||||
|
||||
// Build the search query
|
||||
let mut query = SearchQuery::new().credits(3, 6);
|
||||
|
||||
if let Some(title) = title {
|
||||
query = query.title(title);
|
||||
}
|
||||
|
||||
if let Some(code) = code {
|
||||
let (low, high) = parse_course_code(&code)?;
|
||||
query = query.course_numbers(low, high);
|
||||
}
|
||||
|
||||
if let Some(keywords) = keywords {
|
||||
let keyword_list: Vec<String> =
|
||||
keywords.split_whitespace().map(|s| s.to_string()).collect();
|
||||
query = query.keywords(keyword_list);
|
||||
}
|
||||
|
||||
if let Some(max_results) = max {
|
||||
query = query.max_results(max_results.min(25)); // Cap at 25
|
||||
}
|
||||
|
||||
// TODO: Get current term dynamically
|
||||
let term = "202510"; // Hardcoded for now
|
||||
|
||||
// TODO: Get BannerApi from context or global state
|
||||
// For now, we'll return an error
|
||||
ctx.say("Search functionality not yet implemented - BannerApi integration needed")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse course code input (e.g., "3743", "3000-3999", "3xxx", "3000-")
|
||||
fn parse_course_code(input: &str) -> Result<(i32, i32), Error> {
|
||||
let input = input.trim();
|
||||
|
||||
// Handle range format (e.g., "3000-3999")
|
||||
if input.contains('-') {
|
||||
let re = Regex::new(r"(\d{1,4})-(\d{1,4})?").unwrap();
|
||||
if let Some(captures) = re.captures(input) {
|
||||
let low: i32 = captures[1].parse()?;
|
||||
let high = if captures.get(2).is_some() {
|
||||
captures[2].parse()?
|
||||
} else {
|
||||
9999 // Open-ended range
|
||||
};
|
||||
|
||||
if low > high {
|
||||
return Err("Invalid range: low value greater than high value".into());
|
||||
}
|
||||
|
||||
if low < 1000 || high > 9999 {
|
||||
return Err("Course codes must be between 1000 and 9999".into());
|
||||
}
|
||||
|
||||
return Ok((low, high));
|
||||
}
|
||||
return Err("Invalid range format".into());
|
||||
}
|
||||
|
||||
// Handle wildcard format (e.g., "34xx")
|
||||
if input.contains('x') {
|
||||
if input.len() != 4 {
|
||||
return Err("Wildcard format must be exactly 4 characters".into());
|
||||
}
|
||||
|
||||
let re = Regex::new(r"(\d+)(x+)").unwrap();
|
||||
if let Some(captures) = re.captures(input) {
|
||||
let prefix: i32 = captures[1].parse()?;
|
||||
let x_count = captures[2].len();
|
||||
|
||||
let low = prefix * 10_i32.pow(x_count as u32);
|
||||
let high = low + 10_i32.pow(x_count as u32) - 1;
|
||||
|
||||
if low < 1000 || high > 9999 {
|
||||
return Err("Course codes must be between 1000 and 9999".into());
|
||||
}
|
||||
|
||||
return Ok((low, high));
|
||||
}
|
||||
return Err("Invalid wildcard format".into());
|
||||
}
|
||||
|
||||
// Handle single course code
|
||||
if input.len() == 4 {
|
||||
let code: i32 = input.parse()?;
|
||||
if code < 1000 || code > 9999 {
|
||||
return Err("Course codes must be between 1000 and 9999".into());
|
||||
}
|
||||
return Ok((code, code));
|
||||
}
|
||||
|
||||
Err("Invalid course code format".into())
|
||||
}
|
||||
26
src/bot/commands/terms.rs
Normal file
26
src/bot/commands/terms.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! Terms command implementation.
|
||||
|
||||
use crate::bot::{Context, Error};
|
||||
|
||||
/// List available terms or search for a specific term
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn terms(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Term to search for"] search: Option<String>,
|
||||
#[description = "Page number"] page: Option<i32>,
|
||||
) -> Result<(), Error> {
|
||||
ctx.defer().await?;
|
||||
|
||||
let search_term = search.unwrap_or_default();
|
||||
let page_number = page.unwrap_or(1).max(1);
|
||||
|
||||
// TODO: Get BannerApi from context or global state
|
||||
// For now, we'll return a placeholder response
|
||||
ctx.say(format!(
|
||||
"Terms command not yet implemented - BannerApi integration needed\nSearch: '{}', Page: {}",
|
||||
search_term, page_number
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
25
src/bot/commands/time.rs
Normal file
25
src/bot/commands/time.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Time command implementation for course meeting times.
|
||||
|
||||
use crate::bot::{Context, Error};
|
||||
|
||||
/// Get meeting times for a specific course
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn time(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Course Reference Number (CRN)"] crn: i32,
|
||||
) -> Result<(), Error> {
|
||||
ctx.defer().await?;
|
||||
|
||||
// TODO: Get BannerApi from context or global state
|
||||
// TODO: Get current term dynamically
|
||||
let term = 202510; // Hardcoded for now
|
||||
|
||||
// TODO: Implement actual meeting time retrieval
|
||||
ctx.say(format!(
|
||||
"Time command not yet implemented - BannerApi integration needed\nCRN: {}, Term: {}",
|
||||
crn, term
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
use poise::serenity_prelude as serenity;
|
||||
pub struct Data {} // User data, which is stored and accessible in all command invocations
|
||||
use crate::app_state::AppState;
|
||||
|
||||
pub mod commands;
|
||||
|
||||
pub struct Data {
|
||||
pub app_state: AppState,
|
||||
} // User data, which is stored and accessible in all command invocations
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
|
||||
/// Displays your or another user's account creation date
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn age(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Selected user"] user: Option<serenity::User>,
|
||||
) -> Result<(), Error> {
|
||||
let u = user.as_ref().unwrap_or_else(|| ctx.author());
|
||||
let response = format!("{}'s account was created at {}", u.name, u.created_at());
|
||||
ctx.say(response).await?;
|
||||
Ok(())
|
||||
/// Get all available commands
|
||||
pub fn get_commands() -> Vec<poise::Command<Data, Error>> {
|
||||
vec![
|
||||
commands::search(),
|
||||
commands::terms(),
|
||||
commands::time(),
|
||||
commands::ics(),
|
||||
commands::gcal(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use fundu::{DurationParser, TimeUnit};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Application configuration loaded from environment variables.
|
||||
/// Application configuration loaded from environment variables
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
/// Discord bot token for authentication
|
||||
@@ -27,8 +27,8 @@ pub struct Config {
|
||||
pub bot_app_id: u64,
|
||||
/// Graceful shutdown timeout duration
|
||||
///
|
||||
/// Accepts both numeric values (seconds) and duration strings.
|
||||
/// Defaults to 8 seconds if not specified.
|
||||
/// Accepts both numeric values (seconds) and duration strings
|
||||
/// Defaults to 8 seconds if not specified
|
||||
#[serde(
|
||||
default = "default_shutdown_timeout",
|
||||
deserialize_with = "deserialize_duration"
|
||||
@@ -36,12 +36,12 @@ pub struct Config {
|
||||
pub shutdown_timeout: Duration,
|
||||
}
|
||||
|
||||
/// Default shutdown timeout of 8 seconds.
|
||||
/// Default shutdown timeout of 8 seconds
|
||||
fn default_shutdown_timeout() -> Duration {
|
||||
Duration::from_secs(8)
|
||||
}
|
||||
|
||||
/// Duration parser configured to handle various time units with seconds as default.
|
||||
/// Duration parser configured to handle various time units with seconds as default
|
||||
///
|
||||
/// Supports:
|
||||
/// - Seconds (s) - default unit
|
||||
@@ -49,9 +49,9 @@ fn default_shutdown_timeout() -> Duration {
|
||||
/// - Minutes (m)
|
||||
/// - Hours (h)
|
||||
///
|
||||
/// Does not support fractions, exponents, or infinity values.
|
||||
/// Allows for whitespace between the number and the time unit.
|
||||
/// Allows for multiple time units to be specified (summed together, e.g. "10s 2m" = 120 + 10 = 130 seconds)
|
||||
/// Does not support fractions, exponents, or infinity values
|
||||
/// Allows for whitespace between the number and the time unit
|
||||
/// Allows for multiple time units to be specified (summed together, e.g "10s 2m" = 120 + 10 = 130 seconds)
|
||||
const DURATION_PARSER: DurationParser<'static> = DurationParser::builder()
|
||||
.time_units(&[TimeUnit::Second, TimeUnit::MilliSecond, TimeUnit::Minute])
|
||||
.parse_multiple(None)
|
||||
@@ -62,7 +62,7 @@ const DURATION_PARSER: DurationParser<'static> = DurationParser::builder()
|
||||
.default_unit(TimeUnit::Second)
|
||||
.build();
|
||||
|
||||
/// Custom deserializer for duration fields that accepts both numeric and string values.
|
||||
/// Custom deserializer for duration fields that accepts both numeric and string values
|
||||
///
|
||||
/// This deserializer handles the flexible duration parsing by accepting:
|
||||
/// - Unsigned integers (interpreted as seconds)
|
||||
@@ -74,7 +74,7 @@ const DURATION_PARSER: DurationParser<'static> = DurationParser::builder()
|
||||
/// - `1` -> 1 second
|
||||
/// - `"30s"` -> 30 seconds
|
||||
/// - `"2 m"` -> 2 minutes
|
||||
/// - `"1500ms"` -> 1.5 seconds
|
||||
/// - `"1500ms"` -> 15 seconds
|
||||
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
pub mod app_state;
|
||||
pub mod banner;
|
||||
pub mod bot;
|
||||
pub mod services;
|
||||
pub mod web;
|
||||
|
||||
20
src/main.rs
20
src/main.rs
@@ -3,12 +3,16 @@ use tokio::signal;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing_subscriber::{EnvFilter, FmtSubscriber};
|
||||
|
||||
use crate::bot::{Data, age};
|
||||
use crate::app_state::AppState;
|
||||
use crate::banner::BannerApi;
|
||||
use crate::bot::{Data, get_commands};
|
||||
use crate::config::Config;
|
||||
use crate::services::manager::ServiceManager;
|
||||
use crate::services::{ServiceResult, bot::BotService, run_service};
|
||||
use figment::{Figment, providers::Env};
|
||||
|
||||
mod app_state;
|
||||
mod banner;
|
||||
mod bot;
|
||||
mod config;
|
||||
mod services;
|
||||
@@ -39,17 +43,25 @@ async fn main() {
|
||||
.extract()
|
||||
.expect("Failed to load config");
|
||||
|
||||
// Configure the client with your Discord bot token in the environment.
|
||||
// Create BannerApi and AppState
|
||||
let banner_api =
|
||||
BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi");
|
||||
|
||||
let app_state =
|
||||
AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState");
|
||||
|
||||
// Configure the client with your Discord bot token in the environment
|
||||
let intents = GatewayIntents::non_privileged();
|
||||
|
||||
let bot_target_guild = config.bot_target_guild;
|
||||
|
||||
let framework = poise::Framework::builder()
|
||||
.options(poise::FrameworkOptions {
|
||||
commands: vec![age()],
|
||||
commands: get_commands(),
|
||||
..Default::default()
|
||||
})
|
||||
.setup(move |ctx, _ready, framework| {
|
||||
let app_state = app_state.clone();
|
||||
Box::pin(async move {
|
||||
poise::builtins::register_in_guild(
|
||||
ctx,
|
||||
@@ -58,7 +70,7 @@ async fn main() {
|
||||
)
|
||||
.await?;
|
||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||
Ok(Data {})
|
||||
Ok(Data { app_state })
|
||||
})
|
||||
})
|
||||
.build();
|
||||
|
||||
Reference in New Issue
Block a user