diff --git a/src/app_state.rs b/src/app_state.rs index 6e81a76..4e19e59 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,7 +1,11 @@ //! Application state shared across components (bot, web, scheduler). use crate::banner::BannerApi; +use crate::banner::Course; +use anyhow::Result; +use redis::AsyncCommands; use redis::Client; +use serde_json; #[derive(Clone, Debug)] pub struct AppState { @@ -21,4 +25,24 @@ impl AppState { redis: std::sync::Arc::new(redis_client), }) } + + /// Get a course by CRN with Redis cache fallback to Banner API + pub async fn get_course_or_fetch(&self, term: &str, crn: &str) -> Result { + let mut conn = self.redis.get_multiplexed_async_connection().await?; + + let key = format!("class:{}", crn); + if let Some(serialized) = conn.get::<_, Option>(&key).await? { + let course: Course = serde_json::from_str(&serialized)?; + return Ok(course); + } + + // Fallback: fetch from Banner API + if let Some(course) = self.banner_api.get_course_by_crn(term, crn).await? { + let serialized = serde_json::to_string(&course)?; + let _: () = conn.set(&key, serialized).await?; + return Ok(course); + } + + Err(anyhow::anyhow!("Course not found for CRN {}", crn)) + } } diff --git a/src/banner/api.rs b/src/banner/api.rs index 1fce5eb..b625fa9 100644 --- a/src/banner/api.rs +++ b/src/banner/api.rs @@ -298,6 +298,58 @@ impl BannerApi { self.session_manager.select_term(term).await } + /// Retrieves a single course by CRN by issuing a minimal search + pub async fn get_course_by_crn(&self, term: &str, crn: &str) -> Result> { + self.session_manager.reset_data_form().await?; + // Ensure session is configured for this term + self.select_term(term).await?; + + let session_id = self.session_manager.ensure_session()?; + + let query = SearchQuery::new() + .course_reference_number(crn) + .max_results(1); + + let mut params = query.to_params(); + params.insert("txt_term".to_string(), term.to_string()); + params.insert("uniqueSessionId".to_string(), session_id); + params.insert("sortColumn".to_string(), "subjectDescription".to_string()); + params.insert("sortDirection".to_string(), "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 course by CRN")?; + + let status = response.status(); + let body = response + .text() + .await + .with_context(|| format!("Failed to read body (status={status})"))?; + + let search_result: SearchResult = parse_json_with_context(&body).map_err(|e| { + anyhow::anyhow!( + "Failed to parse search response for CRN (status={status}, url={url}): {e}", + ) + })?; + + if !search_result.success { + return Err(anyhow::anyhow!( + "Search marked as unsuccessful by Banner API" + )); + } + + Ok(search_result + .data + .and_then(|courses| courses.into_iter().next())) + } + /// Gets course details (placeholder - needs implementation). pub async fn get_course_details(&self, term: i32, crn: i32) -> Result { let body = serde_json::json!({ @@ -337,3 +389,40 @@ fn timestamp_nonce() -> 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" } + +/// Attempt to parse JSON and, on failure, include a contextual snippet around the error location +fn parse_json_with_context(body: &str) -> Result { + match serde_json::from_str::(body) { + Ok(value) => Ok(value), + Err(err) => { + let (line, column) = (err.line(), err.column()); + let snippet = build_error_snippet(body, line as usize, column as usize, 120); + Err(anyhow::anyhow!( + "{} at line {}, column {}\nSnippet:\n{}", + err, + line, + column, + snippet + )) + } + } +} + +fn build_error_snippet(body: &str, line: usize, column: usize, max_len: usize) -> String { + let target_line = body.lines().nth(line.saturating_sub(1)).unwrap_or(""); + if target_line.is_empty() { + return String::new(); + } + + let start = column.saturating_sub(max_len.min(column)); + let end = (column + max_len).min(target_line.len()); + let slice = &target_line[start..end]; + + let mut indicator = String::new(); + if column > start { + indicator.push_str(&" ".repeat(column - start - 1)); + indicator.push('^'); + } + + format!("{}\n{}", slice, indicator) +} diff --git a/src/banner/models/courses.rs b/src/banner/models/courses.rs index 0fac585..8b409ea 100644 --- a/src/banner/models/courses.rs +++ b/src/banner/models/courses.rs @@ -32,7 +32,7 @@ pub struct Course { pub campus_description: String, pub schedule_type_description: String, pub course_title: String, - pub credit_hours: i32, + pub credit_hours: Option, pub maximum_enrollment: i32, pub enrollment: i32, pub seats_available: i32, @@ -53,7 +53,9 @@ pub struct Course { pub instructional_method: String, pub instructional_method_description: String, pub section_attributes: Vec, + #[serde(default)] pub faculty: Vec, + #[serde(default)] pub meetings_faculty: Vec, } diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs index 75927be..8921dfa 100644 --- a/src/banner/models/meetings.rs +++ b/src/banner/models/meetings.rs @@ -519,6 +519,7 @@ pub struct MeetingTimeResponse { pub category: Option, pub class: String, pub course_reference_number: String, + #[serde(default)] pub faculty: Vec, pub meeting_time: MeetingTime, pub term: String, diff --git a/src/banner/models/search.rs b/src/banner/models/search.rs index 935a0dd..18b8fd5 100644 --- a/src/banner/models/search.rs +++ b/src/banner/models/search.rs @@ -12,7 +12,7 @@ pub struct SearchResult { pub page_max_size: i32, pub path_mode: String, pub search_results_config: Vec, - pub data: Vec, + pub data: Option>, } /// Search result configuration diff --git a/src/banner/query.rs b/src/banner/query.rs index bd8fa4a..0bb0ed2 100644 --- a/src/banner/query.rs +++ b/src/banner/query.rs @@ -16,6 +16,7 @@ pub struct SearchQuery { subject: Option, title: Option, keywords: Option>, + course_reference_number: Option, open_only: Option, term_part: Option>, campus: Option>, @@ -53,6 +54,12 @@ impl SearchQuery { self } + /// Sets the course reference number (CRN) for the query + pub fn course_reference_number>(mut self, crn: S) -> Self { + self.course_reference_number = Some(crn.into()); + self + } + /// Sets the keywords for the query pub fn keywords(mut self, keywords: Vec) -> Self { self.keywords = Some(keywords); @@ -165,6 +172,10 @@ impl SearchQuery { params.insert("txt_courseTitle".to_string(), title.trim().to_string()); } + if let Some(ref crn) = self.course_reference_number { + params.insert("txt_courseReferenceNumber".to_string(), crn.clone()); + } + if let Some(ref keywords) = self.keywords { params.insert("txt_keywordlike".to_string(), keywords.join(" ")); } diff --git a/src/banner/scraper.rs b/src/banner/scraper.rs index f7659d5..81ea353 100644 --- a/src/banner/scraper.rs +++ b/src/banner/scraper.rs @@ -112,6 +112,9 @@ impl CourseScraper { .offset(offset) .max_results(MAX_PAGE_SIZE * 2); + // Ensure session term is selected before searching + self.api.select_term(term).await?; + let result = self .api .search(term, &query, "subjectDescription", false) @@ -130,7 +133,7 @@ impl CourseScraper { )); } - let course_count = result.data.len() as i32; + let course_count = result.data.as_ref().map(|v| v.len() as i32).unwrap_or(0); total_courses += course_count; debug!( @@ -139,7 +142,7 @@ impl CourseScraper { ); // Store each course in Redis - for course in result.data { + for course in result.data.unwrap_or_default() { if let Err(e) = self.store_course(&course).await { error!( "Failed to store course {}: {}", diff --git a/src/bot/commands/gcal.rs b/src/bot/commands/gcal.rs index 6f359f8..841adf8 100644 --- a/src/bot/commands/gcal.rs +++ b/src/bot/commands/gcal.rs @@ -8,7 +8,7 @@ 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)] +#[poise::command(slash_command)] pub async fn gcal( ctx: Context<'_>, #[description = "Course Reference Number (CRN)"] crn: i32, @@ -25,43 +25,16 @@ pub async fn gcal( let current_term_status = Term::get_current(); let term = current_term_status.inner(); - // TODO: Replace with actual course data when BannerApi::get_course is implemented - let course = Course { - id: 0, - term: term.to_string(), - term_desc: term.to_long_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![], + // Fetch live course data from Redis cache via AppState + let course = match app_state + .get_course_or_fetch(&term.to_string(), &crn.to_string()) + .await + { + Ok(course) => course, + Err(e) => { + error!(%e, crn, "Failed to fetch course data"); + return Err(Error::from(e)); + } }; // Get meeting times diff --git a/src/main.rs b/src/main.rs index b0b13a2..afdabb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,10 @@ async fn main() { // Create BannerApi and AppState let banner_api = BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi"); + banner_api + .setup() + .await + .expect("Failed to set up BannerApi session"); let app_state = AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState");