Skip to main content

ulmensa_lib/
lib.rs

1use std::collections::HashSet;
2
3use once_cell::sync::Lazy;
4use prettytable::{Cell, Row, Table, row};
5use regex::Regex;
6use scraper::{Html, Selector};
7use serde::{Deserialize, Serialize};
8use time::{Duration, OffsetDateTime};
9
10// Compile Regex one time only.
11static RE_CO2: Lazy<Regex> = Lazy::new(|| Regex::new(r"abdruck pro Portion ([0-9\.]+)").unwrap());
12static RE_ENERGY: Lazy<Regex> =
13    Lazy::new(|| Regex::new(r"([0-9,]+) kJ \/ ([0-9,]+) kcal").unwrap());
14static RE_GRAM: Lazy<Regex> = Lazy::new(|| Regex::new(r"([0-9,]+) g").unwrap());
15
16#[allow(dead_code)]
17#[derive(Debug, Serialize, Deserialize)]
18pub struct NutritionalInfo {
19    pub energy_kj: f64,
20    pub energy_kcal: f64,
21    pub protein: f64,
22    pub fat: Vec<f64>,
23    pub carbohydrates: Vec<f64>,
24    pub salt: f64,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
28pub struct Prices {
29    pub student: f64,
30    pub employee: f64,
31    pub guest: f64,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct Dish {
36    pub name: String,
37    pub co2: i32,
38    pub dietary_info: HashSet<String>,
39    pub prices: Prices,
40    pub nutrition: NutritionalInfo,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct Section {
45    pub name: String,
46    pub dishes: Vec<Dish>,
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct Mealplan {
51    pub menu: Vec<Section>,
52}
53
54pub enum Format {
55    Table,
56    TableNutrition,
57    Json,
58}
59
60impl Mealplan {
61    pub async fn from(day_offset: u8) -> Self {
62        let days_offset = Duration::days(day_offset as i64);
63        let date = OffsetDateTime::now_utc()
64            .saturating_add(days_offset)
65            .date()
66            .to_string();
67        let form = [
68            ("func", "make_spl"),
69            ("locId", "1"),
70            ("date", &date),
71            ("lang", "de"),
72            ("startThisWeek", &date),
73            ("startNextWeek", &date),
74        ];
75
76        let client = reqwest::Client::new();
77        let resp = client
78            .post("https://sw-ulm-spl51.maxmanager.xyz/inc/ajax-php_konnektor.inc.php")
79            .form(&form)
80            .send()
81            .await
82            .unwrap()
83            .text()
84            .await
85            .unwrap();
86
87        parse_menu(&resp)
88    }
89
90    pub fn display(&self, format: Format) -> String {
91        match format {
92            Format::Table => display_menu_table(&self.menu),
93            Format::TableNutrition => display_menu_table_nutrition(&self.menu),
94            Format::Json => serde_json::to_string_pretty(&self.menu).unwrap(),
95        }
96    }
97
98    pub fn contains(&self, searchterm: &str) -> bool {
99        for section in &self.menu {
100            for dish in &section.dishes {
101                // Ignore case when searching
102                if dish
103                    .name
104                    .to_lowercase()
105                    .contains(&searchterm.to_lowercase())
106                {
107                    return true;
108                }
109            }
110        }
111        false
112    }
113}
114
115fn display_menu_table(menu: &[Section]) -> String {
116    let mut table = Table::new();
117    table.add_row(row!["Kategorie", "Gericht", "CO2", "Info", "kcal", "Preis"]);
118    for section in menu {
119        for dish in &section.dishes {
120            table.add_row(Row::new(vec![
121                Cell::new(&section.name),
122                Cell::new(&dish.name),
123                Cell::new(&format!("{}g", dish.co2)),
124                Cell::new(
125                    &dish
126                        .dietary_info
127                        .iter()
128                        .fold(String::new(), |acc, el| acc + el + ", "),
129                ),
130                Cell::new(&format!("{:.2}", dish.nutrition.energy_kcal)),
131                Cell::new(&format!(
132                    "{:.2}€|{:.2}€|{:.2}€",
133                    dish.prices.student, dish.prices.employee, dish.prices.guest
134                )),
135            ]));
136        }
137    }
138
139    table.to_string()
140}
141
142fn display_menu_table_nutrition(menu: &[Section]) -> String {
143    let mut table = Table::new();
144    table.add_row(row![
145        "Kategorie",
146        "Gericht",
147        "CO2",
148        "Info",
149        "kcal",
150        "Kohlenhydrate (Zucker)",
151        "Protein",
152        "Fett (ges.)",
153        "Salz",
154        "Preis"
155    ]);
156    for section in menu {
157        for dish in &section.dishes {
158            table.add_row(Row::new(vec![
159                Cell::new(&section.name),
160                Cell::new(&dish.name),
161                Cell::new(&format!("{}g", dish.co2)),
162                Cell::new(
163                    &dish
164                        .dietary_info
165                        .iter()
166                        .fold(String::new(), |acc, el| acc + el + ", "),
167                ),
168                Cell::new(&format!("{:.2}", dish.nutrition.energy_kcal)),
169                Cell::new(&format!(
170                    "{:.2}g ({:.2}g)",
171                    dish.nutrition.carbohydrates[0], dish.nutrition.carbohydrates[1]
172                )),
173                Cell::new(&format!("{:.2}g", dish.nutrition.protein)),
174                Cell::new(&format!(
175                    "{:.2}g ({:.2}g)",
176                    dish.nutrition.fat[0], dish.nutrition.fat[1]
177                )),
178                Cell::new(&format!("{:.2}g", dish.nutrition.salt)),
179                Cell::new(&format!(
180                    "{:.2}€|{:.2}€|{:.2}€",
181                    dish.prices.student, dish.prices.employee, dish.prices.guest
182                )),
183            ]));
184        }
185    }
186
187    table.to_string()
188}
189
190fn extract_prices(price_text: &str) -> Prices {
191    let price_parts: Vec<f64> = price_text
192        .split('|')
193        .filter_map(|part| {
194            part.trim()
195                .trim_start_matches('€')
196                .trim()
197                .replace(',', ".")
198                .parse()
199                .ok()
200        })
201        .collect();
202
203    Prices {
204        student: price_parts[0],
205        employee: price_parts[1],
206        guest: price_parts[2],
207    }
208}
209
210fn extract_name_co2_nutritional_info(input: &str) -> Option<(String, i32, NutritionalInfo)> {
211    //let re_co2 = Regex::new(r"abdruck pro Portion ([0-9\.]+)").unwrap();
212    //let re_energy = Regex::new(r"([0-9,]+) kJ \/ ([0-9,]+) kcal").unwrap();
213    //let re_gram = Regex::new(r"([0-9,]+) g").unwrap();
214    let split = input
215        .split("\n")
216        .map(|x| x.trim())
217        .filter(|&x| !x.is_empty() && !x.starts_with("Nährwertangaben"))
218        .collect::<Vec<&str>>();
219
220    let name_co2 = split[0].split_once("CO2")?;
221    let name = name_co2.0;
222    let co2 = RE_CO2
223        .captures(name_co2.1)?
224        .get(1)?
225        .as_str()
226        .replace(".", "")
227        .parse::<i32>()
228        .ok()?;
229
230    let (_, [kj, kcal]) = RE_ENERGY.captures(split[1])?.extract();
231    let kj = kj.replace(",", ".").parse::<f64>().ok()?;
232    let kcal = kcal.replace(",", ".").parse::<f64>().ok()?;
233
234    let protein = RE_GRAM
235        .captures(split[2])?
236        .get(1)?
237        .as_str()
238        .replace(",", ".")
239        .parse::<f64>()
240        .ok()?;
241
242    let fat = RE_GRAM
243        .captures_iter(split[3])
244        .map(|c| {
245            let (_, [f]) = c.extract();
246            f.replace(",", ".")
247        })
248        .filter_map(|f| f.parse::<f64>().ok())
249        .collect::<Vec<f64>>();
250
251    let carbohydrates = RE_GRAM
252        .captures_iter(split[4])
253        .map(|c| {
254            let (_, [f]) = c.extract();
255            f.replace(",", ".")
256        })
257        .filter_map(|f| f.parse::<f64>().ok())
258        .collect::<Vec<f64>>();
259
260    let (_, [salt]) = RE_GRAM.captures(split[5])?.extract();
261    let salt = salt.replace(",", ".").parse::<f64>().ok()?;
262
263    Some((
264        name.to_string(),
265        co2,
266        NutritionalInfo {
267            energy_kj: kj,
268            energy_kcal: kcal,
269            protein,
270            fat,
271            carbohydrates,
272            salt,
273        },
274    ))
275}
276
277pub fn parse_menu(html_content: &str) -> Mealplan {
278    let document = Html::parse_document(html_content);
279    let section_selector = Selector::parse("div.gruppenkopf").unwrap();
280    let name_selector = Selector::parse("div.gruppenname").unwrap();
281    let dish_text_selector = Selector::parse("div[style*='width:92%']").unwrap();
282    let icon_selector = Selector::parse("img").unwrap();
283    let price_selector = Selector::parse("span").unwrap();
284
285    let mut menu = Vec::new();
286    let mut current_section: Option<Section> = None;
287
288    for element in document.select(&section_selector) {
289        // If we have a previous section, push it to the menu
290        if let Some(section) = current_section {
291            menu.push(section);
292        }
293
294        // Create new section
295        let section_name = element
296            .select(&name_selector)
297            .next()
298            .unwrap()
299            .text()
300            .collect::<String>();
301
302        current_section = Some(Section {
303            name: section_name,
304            dishes: Vec::new(),
305        });
306
307        // Find all dishes until next section
308        let mut next_sibling = element.next_sibling();
309        while let Some(node) = next_sibling {
310            if let Some(elem) = node.value().as_element() {
311                if elem.has_class(
312                    "gruppenkopf",
313                    scraper::CaseSensitivity::AsciiCaseInsensitive,
314                ) {
315                    break;
316                }
317
318                if elem.has_class("splMeal", scraper::CaseSensitivity::AsciiCaseInsensitive) {
319                    let elem_ref = scraper::ElementRef::wrap(node).unwrap();
320
321                    let dish_text = elem_ref
322                        .select(&dish_text_selector)
323                        .next()
324                        .unwrap()
325                        .text()
326                        .collect::<String>();
327
328                    let dietary_info: HashSet<String> = elem_ref
329                        .select(&icon_selector)
330                        .filter_map(|icon| icon.value().attr("title"))
331                        .map(String::from)
332                        .collect();
333
334                    let prices = elem_ref
335                        .select(&price_selector)
336                        .find(|span| span.text().collect::<String>().contains('€'))
337                        .map(|span| extract_prices(&span.text().collect::<String>()))
338                        .unwrap_or(Prices {
339                            student: 0.0,
340                            employee: 0.0,
341                            guest: 0.0,
342                        });
343
344                    if let Some((dish, co2, nutrition)) =
345                        extract_name_co2_nutritional_info(&dish_text)
346                        && let Some(section) = &mut current_section
347                    {
348                        section.dishes.push(Dish {
349                            name: dish,
350                            co2,
351                            dietary_info,
352                            prices,
353                            nutrition,
354                        });
355                    }
356                }
357            }
358            next_sibling = node.next_sibling();
359        }
360    }
361
362    if let Some(section) = current_section {
363        menu.push(section);
364    }
365
366    Mealplan { menu }
367}