Podpora parsování a zobrazení alergenů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful

This commit is contained in:
Martin Berka 2025-10-06 16:28:38 +02:00
parent c2a001b7e5
commit 81f67c8424
Signed by: mates
SSH Key Fingerprint: SHA256:HILXS+ahJ33PQ6YDd3ToEV92OujgFG6CUiFQmvgBx0Q
3 changed files with 80 additions and 13 deletions

View File

@ -30,6 +30,24 @@ const EASTER_EGG_STYLE = {
animationTimingFunction: "ease" animationTimingFunction: "ease"
} }
// Mapování čísel alergenů na jejich názvy
const ALLERGENS: { [key: number]: string } = {
1: "Obiloviny obsahující lepek",
2: "Korýši a výrobky z nich",
3: "Vejce a výrobky z nich",
4: "Ryby a výrobky z nich",
5: "Arašidy a výrobky z nich",
6: "Sója a výrobky z nich",
7: "Mléko a výrobky z nich (včetně laktózy)",
8: "Skořápkové plody",
9: "Celer a výrobky z něj",
10: "Hořčice a výrobky z ní",
11: "Sezamová semena a výrobky z nich",
12: "Oxid siřičitý a siřičitany",
13: "Vlčí bob (Lupina) a výrobky z něj",
14: "Měkkýši a výrobky z nich"
}
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu // Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
const EASTER_EGG_DEFAULT_DURATION = 0.75; const EASTER_EGG_DEFAULT_DURATION = 0.75;
@ -352,7 +370,17 @@ function App() {
(!hideSoups || !f.isSoup) && (!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}> <tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
<td>{f.amount}</td> <td>{f.amount}</td>
<td>{f.name}</td> <td>
{f.name}
{f.allergens && f.allergens.length > 0 && (
<> ({f.allergens.map((a, idx) => (
<span key={a}>
<span title={ALLERGENS[a]} style={{ cursor: 'help', textDecoration: 'underline' }}>{a}</span>
{idx < f.allergens!.length - 1 && ','}
</span>
))})</>
)}
</td>
<td>{f.price}</td> <td>{f.price}</td>
</tr> </tr>
)} )}
@ -412,8 +440,7 @@ function App() {
<img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> */} <img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> */}
Poslední změny: Poslední změny:
<ul> <ul>
<li>Podpora ručního refresh týdne</li> <li>Zobrazení alergenu při najetí myší</li>
<li>Úprava pro přepracovanou podobu stránek Sladovnická</li>
</ul> </ul>
</Alert> </Alert>
{dayIndex != null && {dayIndex != null &&

View File

@ -53,6 +53,28 @@ const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim(); return text.replace('\t', '').replace(' , ', ', ').trim();
} }
/**
* Parsuje čísla alergenů z názvu jídla a vrací vyčištěný název spolu s polem alergenů.
* Alergeny jsou očekávány na konci názvu ve formátu číslic oddělených čárkami.
*
* @param name původní název jídla
* @returns objekt obsahující vyčištěný název a pole alergenů
*/
const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => {
// Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami
const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/;
const match = regex.exec(name);
if (match) {
const allergenString = match[1];
const allergens = allergenString.split(',').map(num => parseInt(num.trim(), 10)).filter(num => !isNaN(num));
const cleanName = name.replace(regex, '').trim();
return { cleanName, allergens };
}
return { cleanName: name, allergens: [] };
}
/** /**
* Stáhne a vrátí aktuální HTML z dané URL. * Stáhne a vrátí aktuální HTML z dané URL.
* *
@ -101,8 +123,9 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
} }
const soupAmount = sanitizeText($(soupCells.get(0)).text()); const soupAmount = sanitizeText($(soupCells.get(0)).text());
const soupName = sanitizeText($(soupCells.get(1)).text()); const soupNameRaw = sanitizeText($(soupCells.get(1)).text());
const soupPrice = sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')); const soupPrice = sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0'));
const soupParsed = parseAllergens(soupNameRaw);
// Parsování hlavních jídel // Parsování hlavních jídel
const mainCourseElement = dayChildren.get(1); const mainCourseElement = dayChildren.get(1);
@ -114,25 +137,28 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
// Přidáme polévku do seznamu jídel // Přidáme polévku do seznamu jídel
currentDayFood.push({ currentDayFood.push({
amount: soupAmount, amount: soupAmount,
name: soupName, name: soupParsed.cleanName,
price: soupPrice, price: soupPrice,
isSoup: true, isSoup: true,
allergens: soupParsed.allergens.length > 0 ? soupParsed.allergens : undefined,
}); });
// Projdeme všechny řádky hlavních jídel // Projdeme všechny řádky hlavních jídel
mainCourseRows.each((i, row) => { mainCourseRows.each((i, row) => {
const cells = $(row).children('td'); const cells = $(row).children('td');
const amount = sanitizeText($(cells.get(0)).text()); const amount = sanitizeText($(cells.get(0)).text());
const name = sanitizeText($(cells.get(1)).text()); const nameRaw = sanitizeText($(cells.get(1)).text());
const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0')); const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
const parsed = parseAllergens(nameRaw);
// Přeskočíme prázdné řádky (první řádek může být prázdný) // Přeskočíme prázdné řádky (první řádek může být prázdný)
if (name.trim().length > 0) { if (parsed.cleanName.trim().length > 0) {
currentDayFood.push({ currentDayFood.push({
amount, amount,
name, name: parsed.cleanName,
price, price,
isSoup: false, isSoup: false,
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
}); });
} }
}); });
@ -293,19 +319,25 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
continue; continue;
} }
let price = 'na\xA0váhu'; let price = 'na\xA0váhu';
let name = text.replace('•', ''); let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) { if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' '); const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2)); const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč` price = `${split.slice(1)[0]}\xA0Kč`
name = split[0].replace('•', ''); nameRaw = split[0].replace('•', '');
} }
if (nameRaw.endsWith('')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= []; result[currentDayIndex] ??= [];
result[currentDayIndex].push({ result[currentDayIndex].push({
amount: '-', amount: '-',
name, name: parsed.cleanName,
price, price,
isSoup: isTextSoupName(name), isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
}) })
} }
} }
@ -405,9 +437,11 @@ export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean =
$('.menicka').each((i, element) => { $('.menicka').each((i, element) => {
const currentDayFood: Food[] = []; const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => { $(element).find('.popup-gallery li').each((j, element) => {
const rawName = $(element).children('div.polozka').text();
const nameWithoutNumber = rawName.replace(/^\d+\.\s*/, '');
currentDayFood.push({ currentDayFood.push({
amount: '-', amount: '-',
name: $(element).children('div.polozka').text(), name: nameWithoutNumber,
price: $(element).children('div.cena').text().replace(/ /g, '\xA0'), price: $(element).children('div.cena').text().replace(/ /g, '\xA0'),
isSoup: $(element).hasClass('polevka'), isSoup: $(element).hasClass('polevka'),
}); });

View File

@ -151,6 +151,12 @@ Food:
isSoup: isSoup:
description: Příznak, zda se jedná o polévku description: Příznak, zda se jedná o polévku
type: boolean type: boolean
allergens:
description: Seznam čísel alergenů obsažených v jídle
type: array
items:
type: integer
example: [1, 3, 7]
RestaurantDayMenu: RestaurantDayMenu:
description: Menu restaurace na konkrétní den description: Menu restaurace na konkrétní den
type: object type: object