mirror of
https://github.com/Xevion/banner.git
synced 2025-12-06 07:14:21 -06:00
feat: continue work on gcal, better meetings schedule types
This commit is contained in:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<BannerApi>,
|
||||
pub redis_client: std::sync::Arc<Client>,
|
||||
pub redis: std::sync::Arc<Client>,
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<MeetingTimeResponse>> {
|
||||
) -> Result<Vec<MeetingScheduleInfo>> {
|
||||
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<MeetingTimeResponse>,
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<u32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
s.parse::<u32>().map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Deserialize a string field into a Term
|
||||
fn deserialize_string_to_term<'de, D>(deserializer: D) -> Result<Term, D::Error>
|
||||
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<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 banner_id: String, // e.g "@01647907" (can contain @ symbol)
|
||||
pub category: Option<String>, // 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<Ordering> {
|
||||
Some(self.bits().cmp(&other.bits()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DayOfWeek> 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<MeetingDays> for DayOfWeek {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(days: MeetingDays) -> Result<Self, Self::Error> {
|
||||
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<Self> {
|
||||
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<NaiveTime> {
|
||||
if time_str.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hours = time_str[..2].parse::<u32>().ok()?;
|
||||
let minutes = time_str[2..].parse::<u32>().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<Ordering> {
|
||||
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<Self> {
|
||||
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> {
|
||||
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<TimeRange>,
|
||||
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<DayOfWeek> {
|
||||
self.days
|
||||
.iter()
|
||||
.map(|day| <MeetingDays as TryInto<DayOfWeek>>::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::<Vec<_>>()
|
||||
.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<Utc>, DateTime<Utc>) {
|
||||
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<i64> {
|
||||
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<Ordering> {
|
||||
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<MeetingTimeResponse>,
|
||||
}
|
||||
|
||||
/// 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<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)
|
||||
/// Get parsed meeting schedule information
|
||||
pub fn schedule_info(&self) -> MeetingScheduleInfo {
|
||||
MeetingScheduleInfo::from_meeting_time(&self.meeting_time)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LinkDetail> = 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::<Result<Vec<LinkDetail>, anyhow::Error>>()?;
|
||||
Ok(links)
|
||||
}
|
||||
}?;
|
||||
|
||||
ctx.say(
|
||||
response
|
||||
.iter()
|
||||
.map(|LinkDetail { link, detail }| {
|
||||
format!("[Add to Google Calendar](<{link}>) ({detail})")
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.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<String, Error> {
|
||||
fn generate_gcal_url(
|
||||
course: &Course,
|
||||
meeting_time: &MeetingScheduleInfo,
|
||||
) -> Result<String, anyhow::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
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ pub async fn search(
|
||||
#[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>,
|
||||
// #[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?;
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user