diff --git a/src/calendar.rs b/src/calendar.rs new file mode 100644 index 0000000..52beebb --- /dev/null +++ b/src/calendar.rs @@ -0,0 +1,462 @@ +//! 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()) +} diff --git a/src/lib.rs b/src/lib.rs index a5119fc..cc6f87b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod banner; pub mod bot; +pub mod calendar; pub mod cli; pub mod config; pub mod data; diff --git a/src/main.rs b/src/main.rs index 294f490..693c209 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use tracing::info; mod app; mod banner; mod bot; +mod calendar; mod cli; mod config; mod data; diff --git a/src/web/calendar.rs b/src/web/calendar.rs new file mode 100644 index 0000000..0a5946b --- /dev/null +++ b/src/web/calendar.rs @@ -0,0 +1,136 @@ +//! Web API endpoints for calendar export (ICS download + Google Calendar redirect). + +use axum::{ + extract::{Path, State}, + http::{StatusCode, header}, + response::{IntoResponse, Redirect, Response}, +}; + +use crate::calendar::{CalendarCourse, generate_gcal_url, generate_ics}; +use crate::data::models::DbMeetingTime; +use crate::state::AppState; + +/// Fetch course + meeting times, build a `CalendarCourse`. +async fn load_calendar_course( + state: &AppState, + term: &str, + crn: &str, +) -> Result<(CalendarCourse, Vec), (StatusCode, String)> { + let course = crate::data::courses::get_course_by_crn(&state.db_pool, crn, term) + .await + .map_err(|e| { + tracing::error!(error = %e, "Calendar: course lookup failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Lookup failed".to_string(), + ) + })? + .ok_or_else(|| (StatusCode::NOT_FOUND, "Course not found".to_string()))?; + + let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id) + .await + .unwrap_or_default(); + + let primary_instructor = instructors + .iter() + .find(|i| i.is_primary) + .or(instructors.first()) + .map(|i| i.display_name.clone()); + + let meeting_times: Vec = + serde_json::from_value(course.meeting_times.clone()).unwrap_or_default(); + + let cal_course = CalendarCourse { + crn: course.crn.clone(), + subject: course.subject.clone(), + course_number: course.course_number.clone(), + title: course.title.clone(), + sequence_number: course.sequence_number.clone(), + primary_instructor, + }; + + Ok((cal_course, meeting_times)) +} + +/// `GET /api/courses/{term}/{crn}/calendar.ics` +/// +/// Returns an ICS file download for the course. +pub async fn course_ics( + State(state): State, + Path((term, crn)): Path<(String, String)>, +) -> Result { + let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?; + + if meeting_times.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + "No meeting times found for this course".to_string(), + )); + } + + let result = generate_ics(&cal_course, &meeting_times).map_err(|e| { + tracing::error!(error = %e, "ICS generation failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate ICS file".to_string(), + ) + })?; + + let response = ( + [ + (header::CONTENT_TYPE, "text/calendar; charset=utf-8"), + ( + header::CONTENT_DISPOSITION, + &format!("attachment; filename=\"{}\"", result.filename), + ), + (header::CACHE_CONTROL, "no-cache"), + ], + result.content, + ) + .into_response(); + + Ok(response) +} + +/// `GET /api/courses/{term}/{crn}/gcal` +/// +/// Redirects to Google Calendar with a pre-filled event for the first meeting time. +/// If multiple meeting times exist, uses the first one with scheduled days/times. +pub async fn course_gcal( + State(state): State, + Path((term, crn)): Path<(String, String)>, +) -> Result { + let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?; + + if meeting_times.is_empty() { + return Err(( + StatusCode::NOT_FOUND, + "No meeting times found for this course".to_string(), + )); + } + + // Prefer the first meeting time that has actual days/times scheduled + let mt = meeting_times + .iter() + .find(|mt| { + mt.begin_time.is_some() + && (mt.monday + || mt.tuesday + || mt.wednesday + || mt.thursday + || mt.friday + || mt.saturday + || mt.sunday) + }) + .unwrap_or(&meeting_times[0]); + + let url = generate_gcal_url(&cal_course, mt).map_err(|e| { + tracing::error!(error = %e, "Google Calendar URL generation failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate Google Calendar URL".to_string(), + ) + })?; + + Ok(Redirect::temporary(&url).into_response()) +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 088843f..b232f67 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -6,6 +6,7 @@ pub mod admin_scraper; #[cfg(feature = "embed-assets")] pub mod assets; pub mod auth; +pub mod calendar; #[cfg(feature = "embed-assets")] pub mod encoding; pub mod extractors; diff --git a/src/web/routes.rs b/src/web/routes.rs index 582c180..74740ab 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -13,6 +13,7 @@ use crate::web::admin; use crate::web::admin_rmp; use crate::web::admin_scraper; use crate::web::auth::{self, AuthConfig}; +use crate::web::calendar; use crate::web::ws; #[cfg(feature = "embed-assets")] use axum::{ @@ -45,6 +46,11 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router { .route("/metrics", get(metrics)) .route("/courses/search", get(search_courses)) .route("/courses/{term}/{crn}", get(get_course)) + .route( + "/courses/{term}/{crn}/calendar.ics", + get(calendar::course_ics), + ) + .route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal)) .route("/terms", get(get_terms)) .route("/subjects", get(get_subjects)) .route("/reference/{category}", get(get_reference)) diff --git a/web/src/lib/components/CourseDetail.svelte b/web/src/lib/components/CourseDetail.svelte index 427d074..9258d7e 100644 --- a/web/src/lib/components/CourseDetail.svelte +++ b/web/src/lib/components/CourseDetail.svelte @@ -16,7 +16,16 @@ import { useClipboard } from "$lib/composables/useClipboard.svelte"; import { cn, tooltipContentClass, formatNumber } from "$lib/utils"; import { Tooltip } from "bits-ui"; import SimpleTooltip from "./SimpleTooltip.svelte"; -import { Info, Copy, Check, Star, Triangle, ExternalLink } from "@lucide/svelte"; +import { + Info, + Copy, + Check, + Star, + Triangle, + ExternalLink, + Calendar, + Download, +} from "@lucide/svelte"; let { course }: { course: CourseResponse } = $props(); @@ -302,5 +311,42 @@ const clipboard = useClipboard(); > {/if} + + + {#if course.meetingTimes.length > 0} +
+

+ + Calendar + + + + +

+ +
+ {/if}