1use std::{borrow::Cow, fmt::Display, str::FromStr};
4
5use chrono::{NaiveDate, NaiveTime};
6use regex::Regex;
7use thiserror::Error;
8use tl::{Node, ParserOptions, VDom, VDomGuard};
9
10use crate::{ParseIdError, Semester};
11
12const CLASSES_PER_PAGE: u32 = 50;
13const CLASSES_PER_GROUP: u32 = 3;
14
15const SESSION_FORMAT: &str = r"^University (\d\d?) Week Session$";
21macro_rules! SESSION_TAG {
22 () => {
23 "SSR_DER_CS_GRP_SESSION_CODE$215$${}"
24 };
25}
26const CLASS_ID_FORMAT: &str = r"^Class Nbr (\d+) - Section ([A-Z](?:\d?)+) ([A-Z]+)$";
30const CLASS_ID_TAG_SEQ: [u32; 3] = [294, 295, 296];
31macro_rules! CLASS_ID_TAG {
32 () => {
33 "SSR_CLSRCH_F_WK_SSR_CMPNT_DESCR_{}${}$${}"
34 };
35}
36const DATES_TIME_FORMAT: &str = "%m/%d/%Y";
38macro_rules! DATES_TAG {
39 () => {
40 "SSR_CLSRCH_F_WK_SSR_MTG_DT_LONG_1$88$${}"
41 };
42}
43const DATETIME_TIME_FORMAT: &str = "%-I:%M%p";
47const DATETIME_FORMAT: &str =
48 r"^((?:[A-Z][a-z]+\s)+)(\d?\d:\d\d(?:AM|PM)) to (\d?\d:\d\d(?:AM|PM))$";
49const DATETIME_TAG_SEQ: [u32; 3] = [134, 135, 154];
50macro_rules! DATETIME_TAG {
51 () => {
52 "SSR_CLSRCH_F_WK_SSR_MTG_SCHED_L_{}${}$${}"
53 };
54}
55macro_rules! ROOM_TAG {
58 () => {
59 "SSR_CLSRCH_F_WK_SSR_MTG_LOC_LONG_{}${}"
60 };
61}
62const INSTRUCTOR_TAG_SEQ: [u32; 3] = [86, 161, 162];
66macro_rules! INSTRUCTOR_TAG {
67 () => {
68 "SSR_CLSRCH_F_WK_SSR_INSTR_LONG_{}${}$${}"
69 };
70}
71const SEATS_FORMAT: &str = r"^Open Seats (\d+) of (\d+)$";
74macro_rules! SEATS_TAG {
75 () => {
76 "SSR_CLSRCH_F_WK_SSR_DESCR50_{}${}"
77 };
78}
79
80#[derive(Debug)]
83pub struct ClassSchedule {
84 dom: VDomGuard,
85 page: u32,
86}
87
88impl ClassSchedule {
89 pub fn new(bytes: Vec<u8>, page: u32) -> Result<Self, ParseError> {
91 let dom = unsafe { tl::parse_owned(String::from_utf8(bytes)?, ParserOptions::default())? };
93
94 Ok(Self { dom, page })
95 }
96
97 pub fn semester(&self) -> Result<Semester, ParseError> {
99 get_text_from_id_without_sub_nodes(self.dom.get_ref(), "TERM_VAL_TBL_DESCR")?
100 .parse::<Semester>()
101 .map_err(|err| err.into())
102 }
103
104 pub fn group_iter(&self) -> impl Iterator<Item = ClassGroup<'_>> + '_ {
109 let first_class_index = self.page.saturating_sub(1) * CLASSES_PER_PAGE;
111 let last_class_index = (self.page * CLASSES_PER_PAGE).saturating_sub(1);
112
113 (first_class_index..last_class_index).map(|group_num| ClassGroup {
114 dom: self.dom.get_ref(),
115 group_num,
116 })
117 }
118}
119
120#[derive(Debug, Clone)]
123pub struct ClassGroup<'a> {
124 dom: &'a VDom<'a>,
125 group_num: u32,
126}
127
128impl<'a> ClassGroup<'a> {
130 pub fn class_iter(&self) -> impl Iterator<Item = Class<'a>> + '_ {
132 (0..CLASSES_PER_GROUP).map(|class_num| Class {
133 dom: self.dom,
134 class_num,
135 group_num: self.group_num,
136 })
137 }
138
139 pub fn session(&self) -> Result<u32, ParseError> {
144 let session =
145 get_text_from_id_without_sub_nodes(self.dom, &format!(SESSION_TAG!(), self.group_num))?;
146 let re = Regex::new(SESSION_FORMAT)
147 .unwrap()
148 .captures(session)
149 .ok_or(ParseError::UnknownElementFormat)?;
150 re.get(1)
151 .ok_or(ParseError::UnknownElementFormat)?
152 .as_str()
153 .parse()
154 .map_err(|_| ParseError::UnknownElementFormat)
155 }
156
157 pub fn start_date(&self) -> Result<NaiveDate, ParseError> {
159 Ok(self.dates()?.0)
160 }
161
162 pub fn end_date(&self) -> Result<NaiveDate, ParseError> {
164 Ok(self.dates()?.1)
165 }
166
167 fn dates(&self) -> Result<(NaiveDate, NaiveDate), ParseError> {
169 let dates =
170 get_text_from_id_without_sub_nodes(self.dom, &format!(DATES_TAG!(), self.group_num))?;
171
172 let mut split_dates = dates.split(" - ");
173 Ok((
175 NaiveDate::parse_from_str(
176 split_dates.next().ok_or(ParseError::UnknownElementFormat)?,
177 DATES_TIME_FORMAT,
178 )
179 .or(Err(ParseError::UnknownElementFormat))?,
180 NaiveDate::parse_from_str(
181 split_dates.next().ok_or(ParseError::UnknownElementFormat)?,
182 DATES_TIME_FORMAT,
183 )
184 .or(Err(ParseError::UnknownElementFormat))?,
185 ))
186 }
187}
188
189#[derive(Debug, Clone)]
192pub struct Class<'a> {
193 dom: &'a VDom<'a>,
194 class_num: u32,
195 group_num: u32,
196}
197
198impl Class<'_> {
199 pub fn is_open(&self) -> Result<bool, ParseError> {
201 let seats = get_text_from_id_without_sub_nodes(
202 self.dom,
203 &format!(SEATS_TAG!(), self.class_num + 1, self.group_num),
204 )?;
205
206 if seats == "Closed" {
207 return Ok(false);
208 }
209
210 Ok(true)
211 }
212
213 pub fn class_type(&self) -> Result<ClassType, ParseError> {
218 self.class_info()
219 .map(|info| info.2.parse().map_err(|_| ParseError::UnknownElementFormat))?
220 }
221
222 pub fn class_id(&self) -> Result<u32, ParseError> {
227 self.class_info()
228 .map(|info| info.0.parse().map_err(|_| ParseError::UnknownElementFormat))?
229 }
230
231 pub fn section(&self) -> Result<&str, ParseError> {
236 self.class_info().map(|info| info.1)
237 }
238
239 pub fn days_of_week(&self) -> Result<Option<Vec<Result<DayOfWeek, ParseError>>>, ParseError> {
242 self.datetime().map(|result| {
243 result.map(|datetime| {
244 datetime
245 .0
246 .iter()
247 .map(|days| days.parse().map_err(|_| ParseError::UnknownElementFormat))
248 .collect()
249 })
250 })
251 }
252
253 pub fn start_time(&self) -> Result<Option<NaiveTime>, ParseError> {
255 self.datetime()
256 .map(|result| {
257 result.map(|datetime| {
258 NaiveTime::parse_from_str(&datetime.1, DATETIME_TIME_FORMAT)
259 .map_err(|_| ParseError::UnknownElementFormat)
260 })
261 })?
262 .transpose()
263 }
264
265 pub fn end_time(&self) -> Result<Option<NaiveTime>, ParseError> {
267 self.datetime()
269 .map(|result| {
270 result.map(|datetime| {
271 NaiveTime::parse_from_str(&datetime.2, DATETIME_TIME_FORMAT)
272 .map_err(|_| ParseError::UnknownElementFormat)
273 })
274 })?
275 .transpose()
276 }
277
278 pub fn room(&self) -> Result<&str, ParseError> {
284 get_text_from_id_without_sub_nodes(
286 self.dom,
287 &format!(ROOM_TAG!(), self.class_num + 1, self.group_num),
288 )
289 }
290
291 pub fn instructor(&self) -> Result<&str, ParseError> {
297 get_text_from_id_without_sub_nodes(
300 self.dom,
301 &format!(
302 INSTRUCTOR_TAG!(),
303 self.class_num + 1,
304 INSTRUCTOR_TAG_SEQ[self.class_num as usize],
305 self.group_num
306 ),
307 )
308 }
309
310 pub fn open_seats(&self) -> Result<Option<u32>, ParseError> {
315 self.seats().map(|seats| seats.map(|seats| seats.0))
316 }
317
318 pub fn total_seats(&self) -> Result<Option<u32>, ParseError> {
323 self.seats().map(|seats| seats.map(|seats| seats.1))
324 }
325
326 fn class_info(&self) -> Result<(&str, &str, &str), ParseError> {
329 let class_info = get_text_from_id_without_sub_nodes(
330 self.dom,
331 &format!(
332 CLASS_ID_TAG!(),
333 self.class_num + 1,
334 CLASS_ID_TAG_SEQ[self.class_num as usize],
335 self.group_num
336 ),
337 )?;
338
339 let re = Regex::new(CLASS_ID_FORMAT)
340 .unwrap()
341 .captures(class_info)
342 .ok_or(ParseError::UnknownElementFormat)?;
343 Ok((
344 re.get(1).ok_or(ParseError::UnknownElementFormat)?.as_str(),
345 re.get(2).ok_or(ParseError::UnknownElementFormat)?.as_str(),
346 re.get(3).ok_or(ParseError::UnknownElementFormat)?.as_str(),
347 ))
348 }
349
350 fn datetime(&self) -> Result<Option<(Vec<String>, String, String)>, ParseError> {
353 get_node_from_id(
354 self.dom,
355 &format!(
356 DATETIME_TAG!(),
357 self.class_num + 1,
358 DATETIME_TAG_SEQ[self.class_num as usize],
359 self.group_num
360 ),
361 )
362 .map_or_else(
366 |err| match err {
367 ParseError::MissingTag => Ok(None),
368 _ => Err(err),
369 },
370 |node| {
371 match node.inner_text(self.dom.parser()) {
372 Cow::Borrowed(_) => Err(ParseError::UnknownHtmlFormat),
373 Cow::Owned(value) => {
374 let re = Regex::new(DATETIME_FORMAT)
375 .unwrap()
376 .captures(&value)
377 .ok_or(ParseError::UnknownElementFormat)?;
378
379 Ok(Some((
380 re.get(1)
381 .ok_or(ParseError::UnknownElementFormat)?
382 .as_str()
383 .split_whitespace()
384 .map(|string| string.to_owned())
385 .collect(), re.get(2)
387 .ok_or(ParseError::UnknownElementFormat)?
388 .as_str()
389 .to_owned(), re.get(3)
391 .ok_or(ParseError::UnknownElementFormat)?
392 .as_str()
393 .to_owned(), )))
395 }
396 }
397 },
398 )
399 }
400
401 fn seats(&self) -> Result<Option<(u32, u32)>, ParseError> {
404 let seats = get_text_from_id_without_sub_nodes(
405 self.dom,
406 &format!(SEATS_TAG!(), self.class_num + 1, self.group_num),
407 )?;
408
409 match seats {
410 "Closed" => Ok(None),
411 _ => {
412 let re = Regex::new(SEATS_FORMAT)
413 .unwrap()
414 .captures(seats)
415 .ok_or(ParseError::UnknownElementFormat)?;
416
417 Ok(Some((
418 re.get(1)
419 .ok_or(ParseError::UnknownElementFormat)?
420 .as_str()
421 .parse()
422 .map_err(|_| ParseError::UnknownElementFormat)?, re.get(2)
424 .ok_or(ParseError::UnknownHtmlFormat)?
425 .as_str()
426 .parse()
427 .map_err(|_| ParseError::UnknownElementFormat)?, )))
429 }
430 }
431 }
432}
433
434#[derive(Debug, Clone, Copy)]
436pub enum ClassType {
437 Recitation,
438 Lab,
439 Lecture,
440 Seminar,
441}
442
443impl FromStr for ClassType {
444 type Err = ParseError;
445
446 fn from_str(s: &str) -> Result<Self, Self::Err> {
447 Ok(match s {
448 "REC" => ClassType::Recitation,
449 "LAB" => ClassType::Lab,
450 "LEC" => ClassType::Lecture,
451 "SEM" => ClassType::Seminar,
452 _ => return Err(ParseError::UnknownElementFormat),
453 })
454 }
455}
456
457impl Display for ClassType {
458 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459 write!(
460 f,
461 "{}",
462 match self {
463 ClassType::Recitation => "Recitation",
464 ClassType::Lab => "Lab",
465 ClassType::Lecture => "Lecture",
466 ClassType::Seminar => "Seminar",
467 }
468 )
469 }
470}
471
472#[derive(Debug, Clone, Copy)]
474pub enum DayOfWeek {
475 Sunday,
476 Monday,
477 Tuesday,
478 Wednesday,
479 Thursday,
480 Friday,
481 Saturday,
482}
483
484impl FromStr for DayOfWeek {
485 type Err = ParseError;
486
487 fn from_str(s: &str) -> Result<Self, Self::Err> {
488 Ok(match s {
489 "Sunday" => DayOfWeek::Sunday,
490 "Monday" => DayOfWeek::Monday,
491 "Tuesday" => DayOfWeek::Tuesday,
492 "Wednesday" => DayOfWeek::Wednesday,
493 "Thursday" => DayOfWeek::Thursday,
494 "Friday" => DayOfWeek::Friday,
495 "Saturday" => DayOfWeek::Saturday,
496 _ => return Err(ParseError::UnknownElementFormat),
497 })
498 }
499}
500
501impl Display for DayOfWeek {
502 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503 write!(
504 f,
505 "{}",
506 match self {
507 DayOfWeek::Sunday => "Sunday",
508 DayOfWeek::Monday => "Monday",
509 DayOfWeek::Tuesday => "Tuesday",
510 DayOfWeek::Wednesday => "Wednesday",
511 DayOfWeek::Thursday => "Thursday",
512 DayOfWeek::Friday => "Friday",
513 DayOfWeek::Saturday => "Saturday",
514 }
515 )
516 }
517}
518
519fn get_text_from_id_without_sub_nodes<'a>(dom: &'a VDom, id: &str) -> Result<&'a str, ParseError> {
521 match get_node_from_id(dom, id)?.inner_text(dom.parser()) {
522 Cow::Borrowed(string) => Ok(string),
523 Cow::Owned(_) => Err(ParseError::UnknownHtmlFormat),
527 }
528}
529
530fn get_node_from_id<'a>(dom: &'a VDom, id: &str) -> Result<&'a Node<'a>, ParseError> {
532 Ok(dom
533 .get_element_by_id(id)
534 .ok_or(ParseError::MissingTag)?
535 .get(dom.parser())
536 .unwrap())
538}
539
540#[derive(Debug, Error)]
542pub enum ParseError {
543 #[error(transparent)]
545 UnknownIdFormat(#[from] ParseIdError),
546 #[error("could not parse HTML due to invalid Utf-8 encoding")]
548 HtmlInvalidUtf8(#[from] std::string::FromUtf8Error),
550 #[error("could not parse HTML due to invalid format")]
552 InvalidHtmlFormat(#[from] tl::errors::ParseError),
553 #[error("could not find tags in HTML")]
555 EmptyHtml,
556 #[error("format of HTML could not be parsed because it is unknown")]
558 UnknownHtmlFormat,
559 #[error("format of element could not be parsed because it is unknown")]
562 UnknownElementFormat,
563 #[error("could not find tag for class in HTML")]
565 MissingTag,
566}