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
10static 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 §ion.dishes {
101 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 §ion.dishes {
120 table.add_row(Row::new(vec![
121 Cell::new(§ion.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 §ion.dishes {
158 table.add_row(Row::new(vec![
159 Cell::new(§ion.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 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(§ion_selector) {
289 if let Some(section) = current_section {
291 menu.push(section);
292 }
293
294 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 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}