feat: continue work on gcal, better meetings schedule types

This commit is contained in:
2025-08-26 23:57:06 -05:00
parent 31ab29c2f1
commit a01a30d047
9 changed files with 641 additions and 347 deletions

5
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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),
})
}
}

View File

@@ -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.

View File

@@ -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::*;

View File

@@ -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)
}
}

View File

@@ -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(&central_tz),
end.with_timezone(&central_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
}
}

View File

@@ -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());

View File

@@ -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