mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
feat: continue work on gcal, better meetings schedule types
This commit is contained in:
Generated
+5
@@ -170,6 +170,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bitflags 2.9.3",
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"compile-time",
|
"compile-time",
|
||||||
@@ -187,6 +188,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serenity",
|
"serenity",
|
||||||
"thiserror 2.0.16",
|
"thiserror 2.0.16",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -216,6 +218,9 @@ name = "bitflags"
|
|||||||
version = "2.9.3"
|
version = "2.9.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
|
checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ rand = "0.8"
|
|||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
compile-time = "0.2.0"
|
compile-time = "0.2.0"
|
||||||
|
time = "0.3.41"
|
||||||
|
bitflags = { version = "2.9.3", features = ["serde"] }
|
||||||
|
|||||||
+3
-3
@@ -3,10 +3,10 @@
|
|||||||
use crate::banner::BannerApi;
|
use crate::banner::BannerApi;
|
||||||
use redis::Client;
|
use redis::Client;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub banner_api: std::sync::Arc<BannerApi>,
|
pub banner_api: std::sync::Arc<BannerApi>,
|
||||||
pub redis_client: std::sync::Arc<Client>,
|
pub redis: std::sync::Arc<Client>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -18,7 +18,7 @@ impl AppState {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
banner_api: std::sync::Arc::new(banner_api),
|
banner_api: std::sync::Arc::new(banner_api),
|
||||||
redis_client: std::sync::Arc::new(redis_client),
|
redis: std::sync::Arc::new(redis_client),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-6
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::banner::{SessionManager, models::*, query::SearchQuery};
|
use crate::banner::{SessionManager, models::*, query::SearchQuery};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use axum::http::HeaderValue;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
@@ -199,7 +200,7 @@ impl BannerApi {
|
|||||||
&self,
|
&self,
|
||||||
term: i32,
|
term: i32,
|
||||||
crn: i32,
|
crn: i32,
|
||||||
) -> Result<Vec<MeetingTimeResponse>> {
|
) -> Result<Vec<MeetingScheduleInfo>> {
|
||||||
let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url);
|
let url = format!("{}/searchResults/getFacultyMeetingTimes", self.base_url);
|
||||||
let params = [
|
let params = [
|
||||||
("term", &term.to_string()),
|
("term", &term.to_string()),
|
||||||
@@ -214,17 +215,38 @@ impl BannerApi {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to get meeting times")?;
|
.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)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ResponseWrapper {
|
struct ResponseWrapper {
|
||||||
fmt: Vec<MeetingTimeResponse>,
|
fmt: Vec<MeetingTimeResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let wrapper: ResponseWrapper = response
|
let wrapper: ResponseWrapper = response.json().await.context("Failed to parse response")?;
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse meeting times response")?;
|
|
||||||
|
|
||||||
Ok(wrapper.fmt)
|
Ok(wrapper.fmt.into_iter().map(|m| m.schedule_info()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a search for courses.
|
/// Performs a search for courses.
|
||||||
|
|||||||
+3
-4
@@ -12,8 +12,7 @@ pub mod query;
|
|||||||
pub mod scraper;
|
pub mod scraper;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
pub use api::BannerApi;
|
pub use api::*;
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use query::SearchQuery;
|
pub use query::*;
|
||||||
pub use scraper::CourseScraper;
|
pub use session::*;
|
||||||
pub use session::SessionManager;
|
|
||||||
|
|||||||
+537
-153
@@ -1,32 +1,53 @@
|
|||||||
use chrono::{NaiveDate, NaiveTime, Timelike};
|
use bitflags::{Flags, bitflags};
|
||||||
use serde::{Deserialize, Serialize};
|
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
use std::{cmp::Ordering, str::FromStr};
|
||||||
|
|
||||||
use super::terms::Term;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FacultyItem {
|
pub struct FacultyItem {
|
||||||
pub banner_id: u32, // e.g. 150161
|
pub banner_id: String, // e.g "@01647907" (can contain @ symbol)
|
||||||
pub category: Option<String>, // zero-padded digits
|
pub category: Option<String>, // zero-padded digits
|
||||||
pub class: String, // internal class name
|
pub class: String, // internal class name
|
||||||
pub course_reference_number: u32, // CRN, e.g. 27294
|
#[serde(deserialize_with = "deserialize_string_to_u32")]
|
||||||
pub display_name: String, // "LastName, FirstName"
|
pub course_reference_number: u32, // CRN, e.g 27294
|
||||||
pub email_address: String, // e.g. FirstName.LastName@utsa.edu
|
pub display_name: String, // "LastName, FirstName"
|
||||||
|
pub email_address: String, // e.g. FirstName.LastName@utsaedu
|
||||||
pub primary_indicator: bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MeetingTime {
|
pub struct MeetingTime {
|
||||||
pub start_date: String, // MM/DD/YYYY, e.g. 08/26/2025
|
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 end_date: String, // MM/DD/YYYY, e.g 08/26/2025
|
||||||
pub begin_time: String, // HHMM, e.g. 1000
|
pub begin_time: String, // HHMM, e.g 1000
|
||||||
pub end_time: String, // HHMM, e.g. 1100
|
pub end_time: String, // HHMM, e.g 1100
|
||||||
pub category: String, // unknown meaning, e.g. 01, 02, etc.
|
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 class: String, // internal class name, e.g. net.hedtech.banner.general.overallMeetingTimeDecorator
|
||||||
pub monday: bool, // true if the meeting time occurs on Monday
|
pub monday: bool, // true if the meeting time occurs on Monday
|
||||||
pub tuesday: bool, // true if the meeting time occurs on Tuesday
|
pub tuesday: bool, // true if the meeting time occurs on Tuesday
|
||||||
pub wednesday: bool, // true if the meeting time occurs on Wednesday
|
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 friday: bool, // true if the meeting time occurs on Friday
|
||||||
pub saturday: bool, // true if the meeting time occurs on Saturday
|
pub saturday: bool, // true if the meeting time occurs on Saturday
|
||||||
pub sunday: bool, // true if the meeting time occurs on Sunday
|
pub sunday: bool, // true if the meeting time occurs on Sunday
|
||||||
pub room: String, // e.g. 1.238
|
pub room: String, // e.g. 1238
|
||||||
pub term: Term, // e.g. 202510
|
#[serde(deserialize_with = "deserialize_string_to_term")]
|
||||||
pub building: String, // e.g. NPB
|
pub term: Term, // e.g 202510
|
||||||
pub building_description: String, // e.g. North Paseo Building
|
pub building: String, // e.g NPB
|
||||||
pub campus: String, // campus code, e.g. 11
|
pub building_description: String, // e.g North Paseo Building
|
||||||
pub campus_description: String, // name of campus, e.g. Main Campus
|
pub campus: String, // campus code, e.g 11
|
||||||
pub course_reference_number: String, // CRN, e.g. 27294
|
pub campus_description: String, // name of campus, e.g Main Campus
|
||||||
pub credit_hour_session: f64, // e.g. 3.0
|
pub course_reference_number: String, // CRN, e.g 27294
|
||||||
pub hours_week: f64, // e.g. 3.0
|
pub credit_hour_session: f64, // e.g. 30
|
||||||
pub meeting_schedule_type: String, // e.g. AFF
|
pub hours_week: f64, // e.g. 30
|
||||||
pub meeting_type: String, // e.g. HB, H2, H1, OS, OA, OH, ID, FF
|
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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MeetingTimesApiResponse {
|
pub struct MeetingTimesApiResponse {
|
||||||
pub fmt: Vec<MeetingTimeResponse>,
|
pub fmt: Vec<MeetingTimeResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Meeting time response wrapper.
|
/// Meeting time response wrapper
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MeetingTimeResponse {
|
pub struct MeetingTimeResponse {
|
||||||
@@ -67,128 +571,8 @@ pub struct MeetingTimeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MeetingTimeResponse {
|
impl MeetingTimeResponse {
|
||||||
/// Returns a formatted string representation of the meeting time.
|
/// Get parsed meeting schedule information
|
||||||
pub fn to_string(&self) -> String {
|
pub fn schedule_info(&self) -> MeetingScheduleInfo {
|
||||||
match self.meeting_time.meeting_type.as_str() {
|
MeetingScheduleInfo::from_meeting_time(&self.meeting_time)
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-174
@@ -1,12 +1,14 @@
|
|||||||
//! Google Calendar command implementation.
|
//! Google Calendar command implementation.
|
||||||
|
|
||||||
use crate::banner::{Course, MeetingTime, MeetingTimeResponse};
|
use crate::banner::{Course, MeetingScheduleInfo, TimeRange};
|
||||||
use crate::bot::{Context, Error};
|
use crate::bot::{Context, Error};
|
||||||
use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Timelike, Utc};
|
use chrono::NaiveDate;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use url::Url;
|
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
|
/// Generate a link to create a Google Calendar event for a course
|
||||||
#[poise::command(slash_command, prefix_command)]
|
#[poise::command(slash_command, prefix_command)]
|
||||||
pub async fn gcal(
|
pub async fn gcal(
|
||||||
@@ -64,102 +66,69 @@ pub async fn gcal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get meeting times
|
// Get meeting times
|
||||||
let meeting_times = banner_api.get_course_meeting_time(term, crn).await?;
|
let mut meeting_times = match banner_api.get_course_meeting_time(term, crn).await {
|
||||||
|
Ok(meeting_time) => meeting_time,
|
||||||
if meeting_times.is_empty() {
|
|
||||||
ctx.say("No meeting times found for this course").await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a meeting time that actually meets (not ID or OA types)
|
|
||||||
let meeting_time = meeting_times
|
|
||||||
.iter()
|
|
||||||
.find(|mt| !matches!(mt.meeting_time.meeting_type.as_str(), "ID" | "OA"))
|
|
||||||
.ok_or("Course does not meet at a defined moment in time")?;
|
|
||||||
|
|
||||||
// Generate the Google Calendar URL
|
|
||||||
match generate_gcal_url(&course, meeting_time) {
|
|
||||||
Ok(calendar_url) => {
|
|
||||||
ctx.say(format!("[Add to Google Calendar](<{}>)", calendar_url))
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to generate Google Calendar URL: {}", e);
|
error!("Failed to get meeting times: {}", e);
|
||||||
ctx.say(format!("Error generating calendar link: {}", e))
|
return Err(Error::from(e));
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
info!("gcal command completed for CRN: {}", crn);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate Google Calendar URL for a course
|
/// 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
|
// Get start and end dates
|
||||||
let start_date = meeting_time
|
let (start, end) = {
|
||||||
.start_date()
|
let central_tz = chrono_tz::US::Central;
|
||||||
.ok_or("Could not parse start date")?;
|
let (start, end) = meeting_time.datetime_range();
|
||||||
let end_date = meeting_time.end_date().ok_or("Could not parse end date")?;
|
(
|
||||||
|
start.with_timezone(¢ral_tz),
|
||||||
// Get start and end times - parse from the time string
|
end.with_timezone(¢ral_tz),
|
||||||
let time_str = meeting_time.time_string();
|
|
||||||
let time_parts: Vec<&str> = time_str.split(' ').collect();
|
|
||||||
|
|
||||||
if time_parts.len() < 2 {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid time format: expected at least 2 parts, got {} parts. Time string: '{}'",
|
|
||||||
time_parts.len(),
|
|
||||||
time_str
|
|
||||||
)
|
)
|
||||||
.into());
|
};
|
||||||
}
|
|
||||||
|
|
||||||
let time_range = time_parts[1];
|
|
||||||
let times: Vec<&str> = time_range.split('-').collect();
|
|
||||||
|
|
||||||
if times.len() != 2 {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid time range format: expected 2 parts, got {} parts. Time range: '{}'",
|
|
||||||
times.len(),
|
|
||||||
time_range
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create timestamps in UTC (assuming Central time)
|
|
||||||
let central_tz = chrono_tz::US::Central;
|
|
||||||
|
|
||||||
let dt_start = central_tz
|
|
||||||
.with_ymd_and_hms(
|
|
||||||
start_date.year(),
|
|
||||||
start_date.month(),
|
|
||||||
start_date.day(),
|
|
||||||
start_time.hour(),
|
|
||||||
start_time.minute(),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc);
|
|
||||||
|
|
||||||
let dt_end = central_tz
|
|
||||||
.with_ymd_and_hms(
|
|
||||||
end_date.year(),
|
|
||||||
end_date.month(),
|
|
||||||
end_date.day(),
|
|
||||||
end_time.hour(),
|
|
||||||
end_time.minute(),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc);
|
|
||||||
|
|
||||||
// Format times in UTC for Google Calendar
|
|
||||||
let start_str = dt_start.format("%Y%m%dT%H%M%SZ").to_string();
|
|
||||||
let end_str = dt_end.format("%Y%m%dT%H%M%SZ").to_string();
|
|
||||||
|
|
||||||
// Generate RRULE for recurrence
|
// Generate RRULE for recurrence
|
||||||
let rrule = generate_rrule(meeting_time, end_date);
|
let rrule = generate_rrule(meeting_time, end.date_naive());
|
||||||
|
|
||||||
// Build calendar URL
|
// Build calendar URL
|
||||||
let mut params = HashMap::new();
|
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
|
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
|
// Get instructor name
|
||||||
let instructor_name = if !course.faculty.is_empty() {
|
let instructor_name = if !course.faculty.is_empty() {
|
||||||
@@ -177,7 +146,7 @@ fn generate_gcal_url(course: &Course, meeting_time: &MeetingTimeResponse) -> Res
|
|||||||
"Unknown"
|
"Unknown"
|
||||||
};
|
};
|
||||||
|
|
||||||
let days_text = weekdays_to_string(&meeting_time.meeting_time);
|
let days_text = meeting_time.days_string();
|
||||||
let details_text = format!(
|
let details_text = format!(
|
||||||
"CRN: {}\nInstructor: {}\nDays: {}",
|
"CRN: {}\nInstructor: {}\nDays: {}",
|
||||||
course.course_reference_number, instructor_name, days_text
|
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
|
/// 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();
|
let by_day = meeting_time.days_string();
|
||||||
|
|
||||||
// Handle edge cases where days_string might return "None" or empty
|
// 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
|
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 = "Course code (e.g. 3743, 3000-3999, 3xxx, 3000-)"] code: Option<String>,
|
||||||
#[description = "Maximum number of results"] max: Option<i32>,
|
#[description = "Maximum number of results"] max: Option<i32>,
|
||||||
#[description = "Keywords in title or description (space separated)"] keywords: Option<String>,
|
#[description = "Keywords in title or description (space separated)"] keywords: Option<String>,
|
||||||
#[description = "Instructor name"] instructor: Option<String>,
|
// #[description = "Instructor name"] instructor: Option<String>,
|
||||||
#[description = "Subject (e.g. Computer Science/CS, Mathematics/MAT)"] subject: Option<String>,
|
// #[description = "Subject (e.g Computer Science/CS, Mathematics/MAT)"] subject: Option<String>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
// Defer the response since this might take a while
|
// Defer the response since this might take a while
|
||||||
ctx.defer().await?;
|
ctx.defer().await?;
|
||||||
@@ -41,8 +41,6 @@ pub async fn search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Get current term dynamically
|
// TODO: Get current term dynamically
|
||||||
let term = "202510"; // Hardcoded for now
|
|
||||||
|
|
||||||
// TODO: Get BannerApi from context or global state
|
// TODO: Get BannerApi from context or global state
|
||||||
// For now, we'll return an error
|
// For now, we'll return an error
|
||||||
ctx.say("Search functionality not yet implemented - BannerApi integration needed")
|
ctx.say("Search functionality not yet implemented - BannerApi integration needed")
|
||||||
@@ -51,11 +49,11 @@ pub async fn search(
|
|||||||
Ok(())
|
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> {
|
fn parse_course_code(input: &str) -> Result<(i32, i32), Error> {
|
||||||
let input = input.trim();
|
let input = input.trim();
|
||||||
|
|
||||||
// Handle range format (e.g., "3000-3999")
|
// Handle range format (e.g, "3000-3999")
|
||||||
if input.contains('-') {
|
if input.contains('-') {
|
||||||
let re = Regex::new(r"(\d{1,4})-(\d{1,4})?").unwrap();
|
let re = Regex::new(r"(\d{1,4})-(\d{1,4})?").unwrap();
|
||||||
if let Some(captures) = re.captures(input) {
|
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());
|
return Err("Invalid range format".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle wildcard format (e.g., "34xx")
|
// Handle wildcard format (e.g, "34xx")
|
||||||
if input.contains('x') {
|
if input.contains('x') {
|
||||||
if input.len() != 4 {
|
if input.len() != 4 {
|
||||||
return Err("Wildcard format must be exactly 4 characters".into());
|
return Err("Wildcard format must be exactly 4 characters".into());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::app_state::AppState;
|
|||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Data {
|
pub struct Data {
|
||||||
pub app_state: AppState,
|
pub app_state: AppState,
|
||||||
} // User data, which is stored and accessible in all command invocations
|
} // User data, which is stored and accessible in all command invocations
|
||||||
|
|||||||
Reference in New Issue
Block a user