//! Shared calendar generation logic for ICS files and Google Calendar URLs. //! //! Used by both the Discord bot commands and the web API endpoints. use crate::data::models::DbMeetingTime; use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday}; /// Course metadata needed for calendar generation (shared interface between bot and web). pub struct CalendarCourse { pub crn: String, pub subject: String, pub course_number: String, pub title: String, pub sequence_number: Option, pub primary_instructor: Option, } impl CalendarCourse { /// Display title like "CS 1083 - Introduction to Computer Science" pub fn display_title(&self) -> String { format!("{} {} - {}", self.subject, self.course_number, self.title) } /// Filename-safe identifier: "CS_1083_001" pub fn filename_stem(&self) -> String { format!( "{}_{}{}", self.subject.replace(' ', "_"), self.course_number, self.sequence_number .as_deref() .map(|s| format!("_{s}")) .unwrap_or_default() ) } } // --------------------------------------------------------------------------- // Date parsing helpers // --------------------------------------------------------------------------- /// Parse a date string in either MM/DD/YYYY or YYYY-MM-DD format. fn parse_date(s: &str) -> Option { NaiveDate::parse_from_str(s, "%m/%d/%Y") .or_else(|_| NaiveDate::parse_from_str(s, "%Y-%m-%d")) .ok() } /// Parse an HHMM time string into `NaiveTime`. fn parse_hhmm(s: &str) -> Option { if s.len() != 4 { return None; } let hours = s[..2].parse::().ok()?; let minutes = s[2..].parse::().ok()?; NaiveTime::from_hms_opt(hours, minutes, 0) } /// Active weekdays for a meeting time. fn active_weekdays(mt: &DbMeetingTime) -> Vec { let mapping: [(bool, Weekday); 7] = [ (mt.monday, Weekday::Mon), (mt.tuesday, Weekday::Tue), (mt.wednesday, Weekday::Wed), (mt.thursday, Weekday::Thu), (mt.friday, Weekday::Fri), (mt.saturday, Weekday::Sat), (mt.sunday, Weekday::Sun), ]; mapping .iter() .filter(|(active, _)| *active) .map(|(_, day)| *day) .collect() } /// ICS two-letter day code for RRULE BYDAY. fn ics_day_code(day: Weekday) -> &'static str { match day { Weekday::Mon => "MO", Weekday::Tue => "TU", Weekday::Wed => "WE", Weekday::Thu => "TH", Weekday::Fri => "FR", Weekday::Sat => "SA", Weekday::Sun => "SU", } } /// Location string from a `DbMeetingTime`. fn location_string(mt: &DbMeetingTime) -> String { let building = mt .building_description .as_deref() .or(mt.building.as_deref()) .unwrap_or(""); let room = mt.room.as_deref().unwrap_or(""); let combined = format!("{building} {room}").trim().to_string(); if combined.is_empty() { "Online".to_string() } else { combined } } /// Days display string (e.g. "MWF", "TTh"). fn days_display(mt: &DbMeetingTime) -> String { let weekdays = active_weekdays(mt); if weekdays.is_empty() { return "TBA".to_string(); } weekdays .iter() .map(|d| ics_day_code(*d)) .collect::>() .join("") } /// Escape text for ICS property values. fn escape_ics(text: &str) -> String { text.replace('\\', "\\\\") .replace(';', "\\;") .replace(',', "\\,") .replace('\n', "\\n") .replace('\r', "") } // --------------------------------------------------------------------------- // University holidays (ported from bot/commands/ics.rs) // --------------------------------------------------------------------------- /// Find the nth occurrence of a weekday in a given month/year (1-based). fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> Option { let first = NaiveDate::from_ymd_opt(year, month, 1)?; let days_ahead = (weekday.num_days_from_monday() as i64 - first.weekday().num_days_from_monday() as i64) .rem_euclid(7) as u32; let day = 1 + days_ahead + 7 * (n - 1); NaiveDate::from_ymd_opt(year, month, day) } /// Compute a consecutive range of dates starting from `start` for `count` days. fn date_range(start: NaiveDate, count: i64) -> Vec { (0..count) .filter_map(|i| start.checked_add_signed(Duration::days(i))) .collect() } /// Compute university holidays for a given year. fn compute_holidays_for_year(year: i32) -> Vec<(&'static str, Vec)> { let mut holidays = Vec::new(); // Labor Day: 1st Monday of September if let Some(d) = nth_weekday_of_month(year, 9, Weekday::Mon, 1) { holidays.push(("Labor Day", vec![d])); } // Fall Break: Mon-Tue of Columbus Day week if let Some(mon) = nth_weekday_of_month(year, 10, Weekday::Mon, 2) { holidays.push(("Fall Break", date_range(mon, 2))); } // Day before Thanksgiving if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4) && let Some(wed) = thu.checked_sub_signed(Duration::days(1)) { holidays.push(("Day Before Thanksgiving", vec![wed])); } // Thanksgiving: 4th Thursday + Friday if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4) { holidays.push(("Thanksgiving", date_range(thu, 2))); } // Winter Holiday: Dec 23-31 if let Some(start) = NaiveDate::from_ymd_opt(year, 12, 23) { holidays.push(("Winter Holiday", date_range(start, 9))); } // New Year's Day if let Some(d) = NaiveDate::from_ymd_opt(year, 1, 1) { holidays.push(("New Year's Day", vec![d])); } // MLK Day: 3rd Monday of January if let Some(d) = nth_weekday_of_month(year, 1, Weekday::Mon, 3) { holidays.push(("MLK Day", vec![d])); } // Spring Break: full week starting 2nd Monday of March if let Some(mon) = nth_weekday_of_month(year, 3, Weekday::Mon, 2) { holidays.push(("Spring Break", date_range(mon, 6))); } holidays } /// Get holiday dates within a date range that fall on specific weekdays. fn holiday_exceptions(start: NaiveDate, end: NaiveDate, weekdays: &[Weekday]) -> Vec { let start_year = start.year(); let end_year = end.year(); (start_year..=end_year) .flat_map(compute_holidays_for_year) .flat_map(|(_, dates)| dates) .filter(|&date| date >= start && date <= end && weekdays.contains(&date.weekday())) .collect() } /// Names of excluded holidays (for user-facing messages). fn excluded_holiday_names( start: NaiveDate, end: NaiveDate, exceptions: &[NaiveDate], ) -> Vec { let start_year = start.year(); let end_year = end.year(); let all_holidays: Vec<_> = (start_year..=end_year) .flat_map(compute_holidays_for_year) .collect(); let mut names = Vec::new(); for (holiday_name, holiday_dates) in &all_holidays { for &exc in exceptions { if holiday_dates.contains(&exc) { names.push(format!("{} ({})", holiday_name, exc.format("%a, %b %d"))); } } } names.sort(); names.dedup(); names } // --------------------------------------------------------------------------- // ICS generation // --------------------------------------------------------------------------- /// Result from ICS generation, including the file content and excluded holiday names. pub struct IcsResult { pub content: String, pub filename: String, /// Holiday dates excluded via EXDATE rules, for user-facing messages. #[allow(dead_code)] pub excluded_holidays: Vec, } /// Generate an ICS calendar file for a course. pub fn generate_ics( course: &CalendarCourse, meeting_times: &[DbMeetingTime], ) -> Result { let mut ics = String::new(); let mut all_excluded = Vec::new(); // Header ics.push_str("BEGIN:VCALENDAR\r\n"); ics.push_str("VERSION:2.0\r\n"); ics.push_str("PRODID:-//Banner Bot//Course Calendar//EN\r\n"); ics.push_str("CALSCALE:GREGORIAN\r\n"); ics.push_str("METHOD:PUBLISH\r\n"); ics.push_str(&format!( "X-WR-CALNAME:{}\r\n", escape_ics(&course.display_title()) )); for (index, mt) in meeting_times.iter().enumerate() { let (event, holidays) = generate_ics_event(course, mt, index)?; ics.push_str(&event); all_excluded.extend(holidays); } ics.push_str("END:VCALENDAR\r\n"); Ok(IcsResult { content: ics, filename: format!("{}.ics", course.filename_stem()), excluded_holidays: all_excluded, }) } /// Generate a single VEVENT for one meeting time. fn generate_ics_event( course: &CalendarCourse, mt: &DbMeetingTime, index: usize, ) -> Result<(String, Vec), anyhow::Error> { let start_date = parse_date(&mt.start_date) .ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?; let end_date = parse_date(&mt.end_date) .ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?; let start_time = mt.begin_time.as_deref().and_then(parse_hhmm); let end_time = mt.end_time.as_deref().and_then(parse_hhmm); // DTSTART/DTEND: first occurrence with time, or all-day on start_date let (dtstart, dtend) = match (start_time, end_time) { (Some(st), Some(et)) => { let s = start_date.and_time(st).and_utc(); let e = start_date.and_time(et).and_utc(); ( s.format("%Y%m%dT%H%M%SZ").to_string(), e.format("%Y%m%dT%H%M%SZ").to_string(), ) } _ => { let s = start_date.and_hms_opt(0, 0, 0).unwrap().and_utc(); let e = start_date.and_hms_opt(0, 0, 0).unwrap().and_utc(); ( s.format("%Y%m%dT%H%M%SZ").to_string(), e.format("%Y%m%dT%H%M%SZ").to_string(), ) } }; let event_title = if index > 0 { format!("{} (Meeting {})", course.display_title(), index + 1) } else { course.display_title() }; let instructor = course.primary_instructor.as_deref().unwrap_or("Staff"); let description = format!( "CRN: {}\\nInstructor: {}\\nDays: {}\\nMeeting Type: {}", course.crn, instructor, days_display(mt), mt.meeting_type, ); let location = location_string(mt); let uid = format!( "{}-{}-{}@banner-bot.local", course.crn, index, start_date .and_hms_opt(0, 0, 0) .unwrap() .and_utc() .timestamp() ); let mut event = String::new(); event.push_str("BEGIN:VEVENT\r\n"); event.push_str(&format!("UID:{uid}\r\n")); event.push_str(&format!("DTSTART:{dtstart}\r\n")); event.push_str(&format!("DTEND:{dtend}\r\n")); event.push_str(&format!("SUMMARY:{}\r\n", escape_ics(&event_title))); event.push_str(&format!("DESCRIPTION:{}\r\n", escape_ics(&description))); event.push_str(&format!("LOCATION:{}\r\n", escape_ics(&location))); let weekdays = active_weekdays(mt); let mut holiday_names = Vec::new(); if let (false, Some(st)) = (weekdays.is_empty(), start_time) { let by_day: Vec<&str> = weekdays.iter().map(|d| ics_day_code(*d)).collect(); let until = end_date.format("%Y%m%dT000000Z").to_string(); event.push_str(&format!( "RRULE:FREQ=WEEKLY;BYDAY={};UNTIL={}\r\n", by_day.join(","), until, )); // Holiday exceptions let exceptions = holiday_exceptions(start_date, end_date, &weekdays); if !exceptions.is_empty() { let start_utc = start_date.and_time(st).and_utc(); let exdates: Vec = exceptions .iter() .map(|&d| { d.and_time(start_utc.time()) .and_utc() .format("%Y%m%dT%H%M%SZ") .to_string() }) .collect(); event.push_str(&format!("EXDATE:{}\r\n", exdates.join(","))); } holiday_names = excluded_holiday_names(start_date, end_date, &exceptions); } event.push_str("END:VEVENT\r\n"); Ok((event, holiday_names)) } // --------------------------------------------------------------------------- // Google Calendar URL generation // --------------------------------------------------------------------------- /// Generate a Google Calendar "add event" URL for a single meeting time. pub fn generate_gcal_url( course: &CalendarCourse, mt: &DbMeetingTime, ) -> Result { let start_date = parse_date(&mt.start_date) .ok_or_else(|| anyhow::anyhow!("Invalid start_date: {}", mt.start_date))?; let end_date = parse_date(&mt.end_date) .ok_or_else(|| anyhow::anyhow!("Invalid end_date: {}", mt.end_date))?; let start_time = mt.begin_time.as_deref().and_then(parse_hhmm); let end_time = mt.end_time.as_deref().and_then(parse_hhmm); let dates_text = match (start_time, end_time) { (Some(st), Some(et)) => { let s = start_date.and_time(st); let e = start_date.and_time(et); format!( "{}/{}", s.format("%Y%m%dT%H%M%S"), e.format("%Y%m%dT%H%M%S") ) } _ => { let s = start_date.format("%Y%m%d").to_string(); format!("{s}/{s}") } }; let instructor = course.primary_instructor.as_deref().unwrap_or("Staff"); let details = format!( "CRN: {}\nInstructor: {}\nDays: {}", course.crn, instructor, days_display(mt), ); let location = location_string(mt); let weekdays = active_weekdays(mt); let recur = if !weekdays.is_empty() && start_time.is_some() { let by_day: Vec<&str> = weekdays.iter().map(|d| ics_day_code(*d)).collect(); let until = end_date.format("%Y%m%dT000000Z").to_string(); format!( "RRULE:FREQ=WEEKLY;BYDAY={};UNTIL={}", by_day.join(","), until ) } else { String::new() }; let course_text = course.display_title(); let params: Vec<(&str, &str)> = vec![ ("action", "TEMPLATE"), ("text", &course_text), ("dates", &dates_text), ("details", &details), ("location", &location), ("trp", "true"), ("ctz", "America/Chicago"), ("recur", &recur), ]; let url = url::Url::parse_with_params("https://calendar.google.com/calendar/render", ¶ms)?; Ok(url.to_string()) }