lanis_rs/modules/
timetable.rs

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    /// The names of the Subjects
36    pub subjects: Vec<String>,
37    pub teachers: Vec<String>,
38    /// School hours are **only** available if [Provider::Lanis] is used
39    pub school_hours: Vec<i32>,
40    pub start: DateTime<Utc>,
41    pub end: DateTime<Utc>,
42    /// The room numbers (e.g. B209)
43    pub rooms: Vec<String>,
44    /// Only available if [Provider::Untis] is used
45    pub lesson_text: Option<String>,
46    /// Only available if [Provider::Untis] is used
47    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            // TODO: Implement Untis support
95            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                    // Time of School hours
155                    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                        // Choose next free slot as day
198                        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