1use crate::base::account::UntisSecrets;
2use crate::utils::constants::URL;
3use crate::utils::datetime::merge_naive_date_time_to_datetime;
4use crate::Error;
5use chrono::{DateTime, Days, NaiveDate, NaiveTime, Utc};
6use reqwest::Client;
7use scraper::{Html, Selector};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::fmt::Debug;
11use untis::LessonCode;
12
13#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
14pub enum Provider {
15 Lanis(LanisType),
16 Untis(UntisSecrets),
17}
18
19#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
20pub enum LanisType {
21 All,
22 Own,
23}
24
25#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
26pub struct Week {
27 pub week: NaiveDate,
28 pub week_type: Option<char>,
29 pub entries: Vec<LessonEntry>,
30}
31
32#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
33pub struct LessonEntry {
34 pub status: LessonEntryStatus,
35 pub subjects: Vec<String>,
37 pub teachers: Vec<String>,
38 pub school_hours: Vec<i32>,
40 pub start: DateTime<Utc>,
41 pub end: DateTime<Utc>,
42 pub rooms: Vec<String>,
44 pub lesson_text: Option<String>,
46 pub substitution_text: Option<String>,
48}
49
50impl LessonEntry {
51 pub fn new(
52 status: LessonEntryStatus,
53 subjects: Vec<String>,
54 teachers: Vec<String>,
55 school_hours: Vec<i32>,
56 start: DateTime<Utc>,
57 end: DateTime<Utc>,
58 rooms: Vec<String>,
59 lesson_text: Option<String>,
60 substitution_text: Option<String>,
61 ) -> Self {
62 Self {
63 status,
64 subjects,
65 teachers,
66 school_hours,
67 start,
68 end,
69 rooms,
70 lesson_text,
71 substitution_text,
72 }
73 }
74}
75
76#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
77pub enum LessonEntryStatus {
78 Normal,
79 Abnormal,
80 Cancelled,
81}
82
83impl Week {
84 pub async fn new(provider: Provider, client: &Client, date: NaiveDate) -> Result<Week, Error> {
85 return match provider {
86 Provider::Lanis(LanisType::All) => {
87 let result = lanis(LanisType::All, client).await?;
88 Ok(result)
89 }
90 Provider::Lanis(LanisType::Own) => {
91 let result = lanis(LanisType::Own, client).await?;
92 Ok(result)
93 }
94 Provider::Untis(secrets) => {
96 let result = untis(secrets, date).await?;
97 Ok(result)
98 }
99 };
100
101 async fn lanis(lanis_type: LanisType, client: &Client) -> Result<Week, Error> {
102 let mut week = NaiveDate::parse_from_str("01.01.1970", "%d.%m.%Y")
103 .map_err(|_| Error::Parsing("failed to parse initial date".to_string()))?;
104 let document = get(lanis_type, client).await?;
105
106 let result = parse(&document, &mut week).await?;
107
108 async fn parse(document_text: &String, week: &mut NaiveDate) -> Result<Week, Error> {
109 let document = Html::parse_document(&document_text);
110
111 let tr_selector = Selector::parse("tr").unwrap();
112 let tr_td_selector = Selector::parse("tr>td").unwrap();
113
114 let row = document.select(&tr_selector).nth(1);
115 if row.is_none() {
116 return Err(Error::Html(
117 "there is no timetable row associated with the timetable element"
118 .to_string(),
119 ));
120 }
121 let rows = row.unwrap();
122
123 let day_count = rows.select(&tr_td_selector).count() as i32;
124
125 let date_selector = Selector::parse("div.col-md-6>span").unwrap();
126 let date = document
127 .select(&date_selector)
128 .nth(0)
129 .unwrap()
130 .text()
131 .collect::<String>()
132 .replace("\n", "")
133 .replace(" ", "")
134 .replace("Stundenplangültig", "")
135 .replace("ab", "")
136 .trim()
137 .to_string();
138 let date = NaiveDate::parse_from_str(&date, "%d.%m.%Y").map_err(|_| {
139 Error::DateTime(format!("Failed to parse date string '{}' as Date", date))
140 })?;
141 *week = date;
142
143 let lesson_selector = Selector::parse("div.stunde ").unwrap();
144 let school_hour_time_selector =
145 Selector::parse("span.hidden-xs>span.VonBis>small").unwrap();
146
147 let rows = document.select(&tr_selector);
148 let mut entries = vec![];
149 let mut hour_times = BTreeMap::new();
150
151 let elements = document.select(&school_hour_time_selector);
152
153 for (i, element) in elements.enumerate() {
154 let text = element.text().collect::<String>();
156
157 let time_string = text.replace(" ", "");
158 let mut time_string = time_string.split("-");
159
160 async fn get_time(time_string: &mut String) -> Result<NaiveTime, Error> {
161 NaiveTime::parse_from_str(&format!("{}:00", time_string), "%H:%M:%S")
162 .map_err(|_| {
163 Error::DateTime(format!(
164 "Failed to parse time string '{}' as NaiveTime",
165 time_string
166 ))
167 })
168 }
169
170 let start_time = get_time(&mut time_string.nth(0).unwrap().to_string()).await?;
171 let end_time = get_time(&mut time_string.nth(0).unwrap().to_string()).await?;
172
173 hour_times.insert(i + 1, [start_time, end_time]);
174 }
175
176 let mut claimed_slots: BTreeMap<[i32; 2], bool> = BTreeMap::new();
177 for i in 1..hour_times.len() as i32 + 1 {
178 for j in 1..day_count {
179 claimed_slots.insert([i, j], false);
180 }
181 }
182
183 for (ri, row) in rows.enumerate() {
184 if ri == 0 {
185 continue;
186 }
187 if ri == 1 {
188 continue;
189 }
190
191 let columns = row.select(&tr_td_selector);
192 for (ci, column) in columns.enumerate() {
193 if ci == 0 {
194 continue;
195 }
196
197 let day_hour = {
199 let mut result = [1, 1];
200 for (key, value) in &claimed_slots {
201 if !value {
202 result = *key;
203 break;
204 }
205 }
206 result
207 };
208
209 let day = day_hour[1];
210 let current_school_hour = day_hour[0];
211
212 let attr = column.attr("rowspan");
213 if attr.is_none() {
214 claimed_slots.insert([current_school_hour, day], true);
215 continue;
216 }
217
218 let hours = attr.unwrap().parse::<i32>().map_err(|_| {
219 Error::Parsing("failed to parse rowspan as i32".to_string())
220 })?;
221
222 for lesson in column.select(&lesson_selector) {
223 let subjects = vec![lesson
224 .text()
225 .nth(1)
226 .unwrap()
227 .replace("\n", "")
228 .trim()
229 .to_string()];
230 let rooms = vec![lesson
231 .text()
232 .nth(2)
233 .unwrap()
234 .replace("\n", "")
235 .trim()
236 .to_string()];
237 let mut teachers = Vec::new();
238 for teacher in lesson.text().nth(3).unwrap().split("\n") {
239 if !teacher.trim().is_empty() {
240 teachers.push(teacher.to_string().trim().to_string());
241 }
242 }
243 let school_hours = {
244 if hours >= 2 {
245 let mut result = vec![];
246 for i in current_school_hour..(current_school_hour + hours) {
247 claimed_slots.insert([i, day], true);
248 result.push(i);
249 }
250 result
251 } else {
252 claimed_slots.insert([current_school_hour, day], true);
253 vec![current_school_hour]
254 }
255 };
256
257 let start = merge_naive_date_time_to_datetime(
258 &date.checked_add_days(Days::new((day - 1) as u64)).unwrap(),
259 &hour_times
260 .get(&(school_hours.first().unwrap().clone() as usize))
261 .unwrap()[0],
262 )
263 .map_err(|e| {
264 Error::DateTime(format!(
265 "Failed to parse NaiveDate & NaiveTime as DateTime: {:?}",
266 e
267 ))
268 })?
269 .to_utc();
270
271 let end = merge_naive_date_time_to_datetime(
272 &date.checked_add_days(Days::new((day - 1) as u64)).unwrap(),
273 &hour_times
274 .get(&(school_hours.last().unwrap().clone() as usize))
275 .unwrap()[1],
276 )
277 .map_err(|e| {
278 Error::DateTime(format!(
279 "Failed to parse NaiveDate & NaiveTime as DateTime: {:?}",
280 e
281 ))
282 })?
283 .to_utc();
284
285 entries.push(LessonEntry {
286 status: LessonEntryStatus::Normal,
287 subjects,
288 teachers,
289 school_hours,
290 start,
291 end,
292 rooms,
293 lesson_text: None,
294 substitution_text: None,
295 });
296 }
297 }
298 }
299
300 let week_type_selector = Selector::parse("div.col-md-6.hidden-pdf.hidden-print>div.pull-right.hidden-pdf>span#aktuelleWoche").unwrap();
301 let week_type = {
302 match document.select(&week_type_selector).nth(0) {
303 Some(week_type) => Some(
304 week_type
305 .text()
306 .collect::<String>()
307 .trim()
308 .to_string()
309 .chars()
310 .next()
311 .unwrap(),
312 ),
313 None => None,
314 }
315 };
316
317 let week = Week {
318 week: week.to_owned(),
319 week_type,
320 entries,
321 };
322 Ok(week)
323 }
324
325 async fn get(lanis_type: LanisType, client: &Client) -> Result<String, Error> {
326 match client.get(URL::TIMETABLE).send().await {
327 Ok(response) => {
328 if response.status() != 302 {
329 return Err(Error::Network(format!(
330 "HTTP error status: {}",
331 response.status()
332 )));
333 }
334
335 let location = response.headers().get("Location");
336 if location == None {
337 return Err(Error::Network("no location header".to_string()));
338 }
339 let location = location
340 .unwrap()
341 .to_str()
342 .map_err(|_| {
343 Error::Parsing("failed to parse location header".to_string())
344 })?
345 .to_string();
346
347 match client
348 .get(format!("{}/{}", URL::TIMETABLE, location))
349 .send()
350 .await
351 {
352 Ok(response) => {
353 if !response.status().is_success() {
354 return Err(Error::Network(format!(
355 "HTTP error status: {}",
356 response.status()
357 )));
358 }
359
360 let text = response.text().await.map_err(|_| {
361 Error::Parsing("failed to parse response text".to_string())
362 })?;
363 let html = Html::parse_document(&text);
364
365 let all_selector = Selector::parse("#all").unwrap();
366 let own_selector = Selector::parse("#own").unwrap();
367
368 let select = {
369 match lanis_type {
370 LanisType::All => html.select(&all_selector).nth(0),
371 LanisType::Own => html.select(&own_selector).nth(0),
372 }
373 };
374
375 if select.is_none() {
376 return Err(Error::Html("no matching tbody".to_string()));
377 }
378
379 let result = select.unwrap().html();
380
381 Ok(result)
382 }
383 Err(e) => Err(Error::Network(format!("{}", e))),
384 }
385 }
386 Err(e) => Err(Error::Network(format!("{}", e))),
387 }
388 }
389 Ok(result)
390 }
391
392 async fn untis(untis_secrets: UntisSecrets, week: NaiveDate) -> Result<Week, Error> {
393 let school = tokio::task::spawn_blocking(move || {
394 untis::schools::get_by_name(untis_secrets.school_name.as_str())
395 .map_err(|e| Error::Credentials(format!("failed to get school: '{}'", e)))
396 })
397 .await
398 .map_err(|e| Error::Threading(format!("Failed to join handle: '{}'", e)))??;
399
400 let mut client = tokio::task::spawn_blocking(move || {
401 school
402 .client_login(&untis_secrets.username, &untis_secrets.password)
403 .map_err(|e| Error::Credentials(format!("failed to login: '{}'", e)))
404 })
405 .await
406 .map_err(|e| Error::Threading(format!("Failed to join handle: '{}'", e)))??;
407
408 let timetable = tokio::task::spawn_blocking(move || {
409 client
410 .own_timetable_for_week(&week.into())
411 .map_err(|e| Error::UntisAPI(format!("failed to get timetable: '{}'", e)))
412 })
413 .await
414 .map_err(|e| Error::Threading(format!("Failed to join handle: '{}'", e)))??;
415
416 let mut entries = Vec::new();
417
418 for lesson in timetable {
419 let status = match lesson.code {
420 LessonCode::Regular => LessonEntryStatus::Normal,
421 LessonCode::Irregular => LessonEntryStatus::Abnormal,
422 LessonCode::Cancelled => LessonEntryStatus::Cancelled,
423 };
424
425 let subjects = lesson
426 .subjects
427 .iter()
428 .map(|id| id.name.clone())
429 .collect::<Vec<_>>();
430 let teachers = lesson
431 .teachers
432 .iter()
433 .map(|id| id.name.clone())
434 .collect::<Vec<_>>();
435 let school_hours = Vec::new();
436 let date = lesson.date.to_chrono();
437 let start = merge_naive_date_time_to_datetime(&date, &lesson.start_time)
438 .map_err(|e| {
439 Error::DateTime(format!("Failed to convert start time of lesson: {:?}", e))
440 })?
441 .to_utc();
442 let end = merge_naive_date_time_to_datetime(&date, &lesson.end_time)
443 .map_err(|e| {
444 Error::DateTime(format!("Failed to convert end time of lesson: {:?}", e))
445 })?
446 .to_utc();
447 let rooms = lesson
448 .rooms
449 .iter()
450 .map(|id| id.name.clone())
451 .collect::<Vec<_>>();
452 let lesson_text = if lesson.lstext.is_empty() {
453 None
454 } else {
455 Some(lesson.lstext)
456 };
457 let substitution_text = lesson.subst_text;
458
459 entries.push(LessonEntry::new(
460 status,
461 subjects,
462 teachers,
463 school_hours,
464 start,
465 end,
466 rooms,
467 lesson_text,
468 substitution_text,
469 ));
470 }
471
472 Ok(Week {
473 week,
474 week_type: None,
475 entries,
476 })
477 }
478 }
479}
480