From a01a30d0479d058ad10029350c390a38f20a4dd5 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 26 Aug 2025 23:57:06 -0500 Subject: [PATCH] feat: continue work on gcal, better meetings schedule types --- Cargo.lock | 5 + Cargo.toml | 2 + src/app_state.rs | 6 +- src/banner/api.rs | 34 +- src/banner/mod.rs | 7 +- src/banner/models/meetings.rs | 690 ++++++++++++++++++++++++++-------- src/bot/commands/gcal.rs | 231 +++--------- src/bot/commands/search.rs | 12 +- src/bot/mod.rs | 1 + 9 files changed, 641 insertions(+), 347 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b77e79f..68c11c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "bitflags 2.9.3", "chrono", "chrono-tz", "compile-time", @@ -187,6 +188,7 @@ dependencies = [ "serde_json", "serenity", "thiserror 2.0.16", + "time", "tokio", "tracing", "tracing-subscriber", @@ -216,6 +218,9 @@ name = "bitflags" version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +dependencies = [ + "serde", +] [[package]] name = "block-buffer" diff --git a/Cargo.toml b/Cargo.toml index 435828c..089424f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,5 @@ rand = "0.8" regex = "1.10" url = "2.5" compile-time = "0.2.0" +time = "0.3.41" +bitflags = { version = "2.9.3", features = ["serde"] } diff --git a/src/app_state.rs b/src/app_state.rs index 7ebf422..6e81a76 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -3,10 +3,10 @@ use crate::banner::BannerApi; use redis::Client; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct AppState { pub banner_api: std::sync::Arc, - pub redis_client: std::sync::Arc, + pub redis: std::sync::Arc, } impl AppState { @@ -18,7 +18,7 @@ impl AppState { Ok(Self { banner_api: std::sync::Arc::new(banner_api), - redis_client: std::sync::Arc::new(redis_client), + redis: std::sync::Arc::new(redis_client), }) } } diff --git a/src/banner/api.rs b/src/banner/api.rs index 2daffc9..1990d6e 100644 --- a/src/banner/api.rs +++ b/src/banner/api.rs @@ -2,6 +2,7 @@ use crate::banner::{SessionManager, models::*, query::SearchQuery}; use anyhow::{Context, Result}; +use axum::http::HeaderValue; use reqwest::Client; use serde_json; @@ -199,7 +200,7 @@ impl BannerApi { &self, term: i32, crn: i32, - ) -> Result> { + ) -> Result> { let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url); let params = [ ("term", &term.to_string()), @@ -214,17 +215,38 @@ impl BannerApi { .await .context("Failed to get meeting times")?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to get meeting times: {}", + response.status() + )); + } else if !response + .headers() + .get("Content-Type") + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .starts_with("application/json") + { + return Err(anyhow::anyhow!( + "Unexpected content type: {:?}", + response + .headers() + .get("Content-Type") + .unwrap_or(&HeaderValue::from_static("(empty)")) + .to_str() + .unwrap_or("(non-ascii)") + )); + } + #[derive(serde::Deserialize)] struct ResponseWrapper { fmt: Vec, } - let wrapper: ResponseWrapper = response - .json() - .await - .context("Failed to parse meeting times response")?; + let wrapper: ResponseWrapper = response.json().await.context("Failed to parse response")?; - Ok(wrapper.fmt) + Ok(wrapper.fmt.into_iter().map(|m| m.schedule_info()).collect()) } /// Performs a search for courses. diff --git a/src/banner/mod.rs b/src/banner/mod.rs index 16bf44e..29be4cf 100644 --- a/src/banner/mod.rs +++ b/src/banner/mod.rs @@ -12,8 +12,7 @@ pub mod query; pub mod scraper; pub mod session; -pub use api::BannerApi; +pub use api::*; pub use models::*; -pub use query::SearchQuery; -pub use scraper::CourseScraper; -pub use session::SessionManager; +pub use query::*; +pub use session::*; diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs index 0d676da..62b6e3b 100644 --- a/src/banner/models/meetings.rs +++ b/src/banner/models/meetings.rs @@ -1,32 +1,53 @@ -use chrono::{NaiveDate, NaiveTime, Timelike}; -use serde::{Deserialize, Serialize}; +use bitflags::{Flags, bitflags}; +use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{cmp::Ordering, str::FromStr}; use super::terms::Term; -/// Represents a faculty member associated with a course. +/// Deserialize a string field into a u32 +fn deserialize_string_to_u32<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + s.parse::().map_err(serde::de::Error::custom) +} + +/// Deserialize a string field into a Term +fn deserialize_string_to_term<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + Term::from_str(&s).map_err(serde::de::Error::custom) +} + +/// 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, // 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 banner_id: String, // e.g "@01647907" (can contain @ symbol) + pub category: Option, // zero-padded digits + pub class: String, // internal class name + #[serde(deserialize_with = "deserialize_string_to_u32")] + pub course_reference_number: u32, // CRN, e.g 27294 + pub display_name: String, // "LastName, FirstName" + pub email_address: String, // e.g. FirstName.LastName@utsaedu pub primary_indicator: bool, - pub term: Term, + pub term: String, // e.g "202420" } -/// Meeting time information for a course. +/// 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 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.overallMeetingTimeDecorator 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 @@ -34,27 +55,510 @@ pub struct MeetingTime { 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 room: String, // e.g. 1238 + #[serde(deserialize_with = "deserialize_string_to_term")] + 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. 30 + pub hours_week: f64, // e.g. 30 + 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. +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub struct MeetingDays: u8 { + const Monday = 1 << 0; + const Tuesday = 1 << 1; + const Wednesday = 1 << 2; + const Thursday = 1 << 3; + const Friday = 1 << 4; + const Saturday = 1 << 5; + const Sunday = 1 << 6; + } +} + +impl MeetingDays { + /// Convert from the boolean flags in the raw API response + pub fn from_meeting_time(meeting_time: &MeetingTime) -> MeetingDays { + let mut days = MeetingDays::empty(); + + if meeting_time.monday { + days.insert(MeetingDays::Monday); + } + if meeting_time.tuesday { + days.insert(MeetingDays::Tuesday); + } + if meeting_time.wednesday { + days.insert(MeetingDays::Wednesday); + } + if meeting_time.thursday { + days.insert(MeetingDays::Thursday); + } + if meeting_time.friday { + days.insert(MeetingDays::Friday); + } + if meeting_time.saturday { + days.insert(MeetingDays::Saturday); + } + if meeting_time.sunday { + days.insert(MeetingDays::Sunday); + } + + days + } +} + +impl PartialOrd for MeetingDays { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.bits().cmp(&other.bits())) + } +} + +impl From for MeetingDays { + fn from(day: DayOfWeek) -> Self { + match day { + DayOfWeek::Monday => MeetingDays::Monday, + DayOfWeek::Tuesday => MeetingDays::Tuesday, + DayOfWeek::Wednesday => MeetingDays::Wednesday, + DayOfWeek::Thursday => MeetingDays::Thursday, + DayOfWeek::Friday => MeetingDays::Friday, + DayOfWeek::Saturday => MeetingDays::Saturday, + DayOfWeek::Sunday => MeetingDays::Sunday, + } + } +} + +/// Days of the week for meeting schedules +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum DayOfWeek { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} + +impl DayOfWeek { + /// Convert to short string representation + pub fn to_short_string(&self) -> &'static str { + match self { + DayOfWeek::Monday => "M", + DayOfWeek::Tuesday => "Tu", + DayOfWeek::Wednesday => "W", + DayOfWeek::Thursday => "Th", + DayOfWeek::Friday => "F", + DayOfWeek::Saturday => "Sa", + DayOfWeek::Sunday => "Su", + } + } + + /// Convert to full string representation + pub fn to_string(&self) -> &'static str { + match self { + DayOfWeek::Monday => "Monday", + DayOfWeek::Tuesday => "Tuesday", + DayOfWeek::Wednesday => "Wednesday", + DayOfWeek::Thursday => "Thursday", + DayOfWeek::Friday => "Friday", + DayOfWeek::Saturday => "Saturday", + DayOfWeek::Sunday => "Sunday", + } + } +} + +impl TryFrom for DayOfWeek { + type Error = anyhow::Error; + + fn try_from(days: MeetingDays) -> Result { + if days.contains_unknown_bits() { + return Err(anyhow::anyhow!("Unknown days: {:?}", days)); + } + + let count = days.into_iter().count(); + if count == 1 { + return Ok(match days { + MeetingDays::Monday => DayOfWeek::Monday, + MeetingDays::Tuesday => DayOfWeek::Tuesday, + MeetingDays::Wednesday => DayOfWeek::Wednesday, + MeetingDays::Thursday => DayOfWeek::Thursday, + MeetingDays::Friday => DayOfWeek::Friday, + MeetingDays::Saturday => DayOfWeek::Saturday, + MeetingDays::Sunday => DayOfWeek::Sunday, + _ => unreachable!(), + }); + } + + return Err(anyhow::anyhow!( + "Cannot convert multiple days to a single day: {:?}", + days + )); + } +} + +/// Time range for meetings +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TimeRange { + pub start: NaiveTime, + pub end: NaiveTime, +} + +impl TimeRange { + /// Parse time range from HHMM format strings + pub fn from_hhmm(start: &str, end: &str) -> Option { + let start_time = Self::parse_hhmm(start)?; + let end_time = Self::parse_hhmm(end)?; + + Some(TimeRange { + start: start_time, + end: end_time, + }) + } + + /// Parse HHMM format string to NaiveTime + fn parse_hhmm(time_str: &str) -> Option { + if time_str.len() != 4 { + return None; + } + + let hours = time_str[..2].parse::().ok()?; + let minutes = time_str[2..].parse::().ok()?; + + if hours > 23 || minutes > 59 { + return None; + } + + NaiveTime::from_hms_opt(hours, minutes, 0) + } + + /// Format time in 12-hour format + pub fn format_12hr(&self) -> String { + format!( + "{}-{}", + Self::format_time_12hr(self.start), + Self::format_time_12hr(self.end) + ) + } + + /// Format a single time in 12-hour format + fn format_time_12hr(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) + } + } + + /// Get duration in minutes + pub fn duration_minutes(&self) -> i64 { + let start_minutes = self.start.hour() as i64 * 60 + self.start.minute() as i64; + let end_minutes = self.end.hour() as i64 * 60 + self.end.minute() as i64; + end_minutes - start_minutes + } +} + +impl PartialOrd for TimeRange { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.start.cmp(&other.start)) + } +} + +/// Date range for meetings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DateRange { + pub start: NaiveDate, + pub end: NaiveDate, +} + +impl DateRange { + /// Parse date range from MM/DD/YYYY format strings + pub fn from_mm_dd_yyyy(start: &str, end: &str) -> Option { + let start_date = Self::parse_mm_dd_yyyy(start)?; + let end_date = Self::parse_mm_dd_yyyy(end)?; + + Some(DateRange { + start: start_date, + end: end_date, + }) + } + + /// Parse MM/DD/YYYY format string to NaiveDate + fn parse_mm_dd_yyyy(date_str: &str) -> Option { + NaiveDate::parse_from_str(date_str, "%m/%d/%Y").ok() + } + + /// Get the number of weeks between start and end dates + pub fn weeks_duration(&self) -> u32 { + let duration = self.end.signed_duration_since(self.start); + duration.num_weeks() as u32 + } + + /// Check if a specific date falls within this range + pub fn contains_date(&self, date: NaiveDate) -> bool { + date >= self.start && date <= self.end + } +} + +/// Meeting schedule type enum +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MeetingType { + HybridBlended, // HB, H2, H1 + OnlineSynchronous, // OS + OnlineAsynchronous, // OA + OnlineHybrid, // OH + IndependentStudy, // ID + FaceToFace, // FF + Unknown(String), +} + +impl MeetingType { + /// Parse from the meeting type string + pub fn from_string(s: &str) -> Self { + match s { + "HB" | "H2" | "H1" => MeetingType::HybridBlended, + "OS" => MeetingType::OnlineSynchronous, + "OA" => MeetingType::OnlineAsynchronous, + "OH" => MeetingType::OnlineHybrid, + "ID" => MeetingType::IndependentStudy, + "FF" => MeetingType::FaceToFace, + other => MeetingType::Unknown(other.to_string()), + } + } + + /// Get description for the meeting type + pub fn description(&self) -> &'static str { + match self { + MeetingType::HybridBlended => "Hybrid", + MeetingType::OnlineSynchronous => "Online Only", + MeetingType::OnlineAsynchronous => "Online Asynchronous", + MeetingType::OnlineHybrid => "Online Partial", + MeetingType::IndependentStudy => "To Be Arranged", + MeetingType::FaceToFace => "Face to Face", + MeetingType::Unknown(_) => "Unknown", + } + } +} + +/// Meeting location information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingLocation { + pub campus: String, + pub building: String, + pub building_description: String, + pub room: String, + pub is_online: bool, +} + +impl MeetingLocation { + /// Create from raw MeetingTime data + pub fn from_meeting_time(meeting_time: &MeetingTime) -> Self { + let is_online = meeting_time.room.is_empty(); + + MeetingLocation { + campus: meeting_time.campus_description.clone(), + building: meeting_time.building.clone(), + building_description: meeting_time.building_description.clone(), + room: meeting_time.room.clone(), + is_online, + } + } + + /// Convert to formatted string + pub fn to_string(&self) -> String { + if self.is_online { + "Online".to_string() + } else { + format!( + "{} | {} | {} {}", + self.campus, self.building_description, self.building, self.room + ) + } + } +} + +/// Clean, parsed meeting schedule information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingScheduleInfo { + pub days: MeetingDays, + pub time_range: Option, + pub date_range: DateRange, + pub meeting_type: MeetingType, + pub location: MeetingLocation, + pub duration_weeks: u32, +} + +impl MeetingScheduleInfo { + /// Create from raw MeetingTime data + pub fn from_meeting_time(meeting_time: &MeetingTime) -> Self { + let days = MeetingDays::from_meeting_time(meeting_time); + let time_range = TimeRange::from_hhmm(&meeting_time.begin_time, &meeting_time.end_time); + let date_range = + DateRange::from_mm_dd_yyyy(&meeting_time.start_date, &meeting_time.end_date) + .unwrap_or_else(|| { + // Fallback to current date if parsing fails + let now = chrono::Utc::now().naive_utc().date(); + DateRange { + start: now, + end: now, + } + }); + let meeting_type = MeetingType::from_string(&meeting_time.meeting_type); + let location = MeetingLocation::from_meeting_time(meeting_time); + let duration_weeks = date_range.weeks_duration(); + + MeetingScheduleInfo { + days, + time_range, + date_range, + meeting_type, + location, + duration_weeks, + } + } + + /// Convert the meeting days bitset to a enum vector + pub fn days_of_week(&self) -> Vec { + self.days + .iter() + .map(|day| >::try_into(day).unwrap()) + .collect() + } + + /// Get formatted days string + pub fn days_string(&self) -> String { + if self.days.is_empty() { + "None".to_string() + } else if self.days.is_all() { + "Everyday".to_string() + } else { + self.days_of_week() + .iter() + .map(|day| day.to_short_string()) + .collect::>() + .join("") + } + } + + /// Returns a formatted string representing the location of the meeting + pub fn place_string(&self) -> String { + if self.location.room.is_empty() { + "Online".to_string() + } else { + format!( + "{} | {} | {} {}", + self.location.campus, + self.location.building_description, + self.location.building, + self.location.room + ) + } + } + + /// Get the start and end date times for the meeting + /// + /// Uses the start and end times of the meeting if available, otherwise defaults to midnight (00:00:00.000). + /// + /// The returned times are in UTC. + pub fn datetime_range(&self) -> (DateTime, DateTime) { + let (start, end) = if let Some(time_range) = &self.time_range { + let start = self.date_range.start.and_time(time_range.start); + let end = self.date_range.end.and_time(time_range.end); + (start, end) + } else { + ( + self.date_range.start.and_hms_opt(0, 0, 0).unwrap(), + self.date_range.end.and_hms_opt(0, 0, 0).unwrap(), + ) + }; + + (start.and_utc(), end.and_utc()) + } + + /// Get formatted time string + pub fn time_string(&self) -> String { + match &self.time_range { + Some(time_range) => format!("{} {}", self.days_string(), time_range.format_12hr()), + None => "No Time".to_string(), + } + } + + /// Get formatted schedule string + pub fn schedule_string(&self) -> String { + match self.meeting_type { + MeetingType::HybridBlended => { + format!( + "{}\nHybrid {}", + self.time_string(), + self.location.to_string() + ) + } + MeetingType::OnlineSynchronous => { + format!("{}\nOnline Only", self.time_string()) + } + MeetingType::OnlineAsynchronous => "No Time\nOnline Asynchronous".to_string(), + MeetingType::OnlineHybrid => { + format!("{}\nOnline Partial", self.time_string()) + } + MeetingType::IndependentStudy => "To Be Arranged".to_string(), + MeetingType::FaceToFace => { + format!("{}\n{}", self.time_string(), self.location.to_string()) + } + MeetingType::Unknown(_) => "Unknown".to_string(), + } + } + + pub fn occurs_on_day(&self, day: DayOfWeek) -> bool { + self.days.contains(MeetingDays::from(day)) + } + + /// Get meeting duration in minutes + pub fn duration_minutes(&self) -> Option { + if let Some(time_range) = &self.time_range { + Some(time_range.duration_minutes()) + } else { + None + } + } +} + +impl PartialEq for MeetingScheduleInfo { + fn eq(&self, other: &Self) -> bool { + self.days == other.days && self.time_range == other.time_range + } +} + +impl PartialOrd for MeetingScheduleInfo { + fn partial_cmp(&self, other: &Self) -> Option { + match (&self.time_range, &other.time_range) { + (Some(self_time), Some(other_time)) => self_time.partial_cmp(other_time), + (None, None) => Some(self.days.partial_cmp(&other.days).unwrap()), + (Some(_), None) => Some(Ordering::Less), + (None, Some(_)) => Some(Ordering::Greater), + } + } +} + +/// API response wrapper for meeting times #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeetingTimesApiResponse { pub fmt: Vec, } -/// Meeting time response wrapper. +/// Meeting time response wrapper #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MeetingTimeResponse { @@ -67,128 +571,8 @@ pub struct MeetingTimeResponse { } 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 { - 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::parse_from_str(date_str, "%m/%d/%Y").ok() - } - - /// Get the start date as NaiveDate. - pub fn start_date(&self) -> Option { - Self::parse_date(&self.meeting_time.start_date) - } - - /// Get the end date as NaiveDate. - pub fn end_date(&self) -> Option { - 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) + /// Get parsed meeting schedule information + pub fn schedule_info(&self) -> MeetingScheduleInfo { + MeetingScheduleInfo::from_meeting_time(&self.meeting_time) } } diff --git a/src/bot/commands/gcal.rs b/src/bot/commands/gcal.rs index a36c7ff..a51e974 100644 --- a/src/bot/commands/gcal.rs +++ b/src/bot/commands/gcal.rs @@ -1,12 +1,14 @@ //! Google Calendar command implementation. -use crate::banner::{Course, MeetingTime, MeetingTimeResponse}; +use crate::banner::{Course, MeetingScheduleInfo, TimeRange}; use crate::bot::{Context, Error}; -use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; +use chrono::NaiveDate; use std::collections::HashMap; use tracing::{error, info}; use url::Url; +const TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + /// Generate a link to create a Google Calendar event for a course #[poise::command(slash_command, prefix_command)] pub async fn gcal( @@ -64,102 +66,69 @@ pub async fn gcal( }; // 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?; - } + let mut meeting_times = match banner_api.get_course_meeting_time(term, crn).await { + Ok(meeting_time) => meeting_time, Err(e) => { - error!("Failed to generate Google Calendar URL: {}", e); - ctx.say(format!("Error generating calendar link: {}", e)) - .await?; + error!("Failed to get meeting times: {}", e); + return Err(Error::from(e)); } + }; + + struct LinkDetail { + link: String, + detail: String, } + let response: Vec = match meeting_times.len() { + 0 => Err(anyhow::anyhow!("No meeting times found for this course.")), + 1.. => { + let links = meeting_times + .iter() + .map(|m| { + let link = generate_gcal_url(&course, m)?; + let detail = match &m.time_range { + Some(range) => range.format_12hr(), + None => m.days_string(), + }; + Ok(LinkDetail { link, detail }) + }) + .collect::, anyhow::Error>>()?; + Ok(links) + } + }?; + + ctx.say( + response + .iter() + .map(|LinkDetail { link, detail }| { + format!("[Add to Google Calendar](<{link}>) ({detail})") + }) + .collect::>() + .join("\n"), + ) + .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 { +fn generate_gcal_url( + course: &Course, + meeting_time: &MeetingScheduleInfo, +) -> Result { // 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 + let (start, end) = { + let central_tz = chrono_tz::US::Central; + let (start, end) = meeting_time.datetime_range(); + ( + start.with_timezone(¢ral_tz), + end.with_timezone(¢ral_tz), ) - .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); + let rrule = generate_rrule(meeting_time, end.date_naive()); // Build calendar URL let mut params = HashMap::new(); @@ -168,7 +137,7 @@ fn generate_gcal_url(course: &Course, meeting_time: &MeetingTimeResponse) -> Res "{} {} - {}", course.subject, course.course_number, course.course_title ); - let dates_text = format!("{}/{}", start_str, end_str); + let dates_text = format!("{}/{}", start, end); // Get instructor name let instructor_name = if !course.faculty.is_empty() { @@ -177,7 +146,7 @@ fn generate_gcal_url(course: &Course, meeting_time: &MeetingTimeResponse) -> Res "Unknown" }; - let days_text = weekdays_to_string(&meeting_time.meeting_time); + let days_text = meeting_time.days_string(); let details_text = format!( "CRN: {}\nInstructor: {}\nDays: {}", course.course_reference_number, instructor_name, days_text @@ -205,7 +174,7 @@ fn generate_gcal_url(course: &Course, meeting_time: &MeetingTimeResponse) -> Res } /// Generate RRULE for recurrence -fn generate_rrule(meeting_time: &MeetingTimeResponse, end_date: NaiveDate) -> String { +fn generate_rrule(meeting_time: &MeetingScheduleInfo, end_date: NaiveDate) -> String { let by_day = meeting_time.days_string(); // Handle edge cases where days_string might return "None" or empty @@ -235,89 +204,3 @@ fn generate_rrule(meeting_time: &MeetingTimeResponse, end_date: NaiveDate) -> St rrule } - -/// Parse time from formatted string (e.g., "8:00AM", "12:30PM") -fn parse_time_from_formatted(time_str: &str) -> Result { - 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 - } -} diff --git a/src/bot/commands/search.rs b/src/bot/commands/search.rs index b47d021..579f300 100644 --- a/src/bot/commands/search.rs +++ b/src/bot/commands/search.rs @@ -12,8 +12,8 @@ pub async fn search( #[description = "Course code (e.g. 3743, 3000-3999, 3xxx, 3000-)"] code: Option, #[description = "Maximum number of results"] max: Option, #[description = "Keywords in title or description (space separated)"] keywords: Option, - #[description = "Instructor name"] instructor: Option, - #[description = "Subject (e.g. Computer Science/CS, Mathematics/MAT)"] subject: Option, + // #[description = "Instructor name"] instructor: Option, + // #[description = "Subject (e.g Computer Science/CS, Mathematics/MAT)"] subject: Option, ) -> Result<(), Error> { // Defer the response since this might take a while ctx.defer().await?; @@ -41,8 +41,6 @@ pub async fn search( } // 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") @@ -51,11 +49,11 @@ pub async fn search( Ok(()) } -/// Parse course code input (e.g., "3743", "3000-3999", "3xxx", "3000-") +/// 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") + // 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) { @@ -79,7 +77,7 @@ fn parse_course_code(input: &str) -> Result<(i32, i32), Error> { return Err("Invalid range format".into()); } - // Handle wildcard format (e.g., "34xx") + // Handle wildcard format (e.g, "34xx") if input.contains('x') { if input.len() != 4 { return Err("Wildcard format must be exactly 4 characters".into()); diff --git a/src/bot/mod.rs b/src/bot/mod.rs index ef80daa..175a0f9 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -2,6 +2,7 @@ use crate::app_state::AppState; pub mod commands; +#[derive(Debug)] pub struct Data { pub app_state: AppState, } // User data, which is stored and accessible in all command invocations