Compare commits

...

9 Commits

Author SHA1 Message Date
Kantine Wrapper
1be6e44d7f chore: update build artifacts for v1.6.4 2026-03-05 16:38:09 +01:00
Kantine Wrapper
49b0ab17ac chore: refine language heuristics and dictionary 2026-03-05 16:37:55 +01:00
Kantine Wrapper
55e738a554 chore: update build artifacts for v1.6.3 2026-03-05 12:47:56 +01:00
Kantine Wrapper
45adfa9d5d fix: Correct menu item text extraction from name to description and introduce a language splitting utility. 2026-03-05 12:47:51 +01:00
Kantine Wrapper
f5f6dddba3 chore: update build artifacts for v1.6.3 2026-03-05 12:43:49 +01:00
Kantine Wrapper
b06f6c3551 chore: inject temp menu logger 2026-03-05 12:43:25 +01:00
Kantine Wrapper
b66030dce5 fix: prevent heuristic split on final English-only fragment 2026-03-05 11:56:55 +01:00
Kantine Wrapper
8e7ec468d4 chore: update build artifacts for v1.6.3 2026-03-05 11:46:57 +01:00
Kantine Wrapper
8ce3ae4c92 feat: Update footer slogan, optimize footer height and container padding, and bump version to v1.6.3. 2026-03-05 11:46:51 +01:00
8 changed files with 212 additions and 140 deletions

View File

@@ -1,3 +1,12 @@
## v1.6.4 (2026-03-05)
-**Feature**: Sprach-Lexikon (DE/EN) massiv erweitert um österreichische Begriffe (Nockerl, Fleckerl, Topfen, Mohn, Most etc.) und gängige Tippfehler aus dem Bessa-System (trukey, coffe, oveb etc.).
- 🧹 **Cleanup**: Sprach-Lexikon dedupliziert und alphabetisch sortiert für bessere Performance und Wartbarkeit.
- 🐛 **Bugfix**: Trennung von zweisprachigen Menüs (`splitLanguage`) verbessert: Erfasst nun auch Schrägstriche ohne Leerzeichen (z.B. `Suppe/Soup`).
- 🐛 **Bugfix**: Fehlerhafte Badge-Anzeige korrigiert (Variable `count` vs `orderCount`).
## v1.6.3 (2026-03-05)
-**Chore**: Slogan im Footer aktualisiert ("Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • 2026 by Kaufis-Kitchen") und Footer-Höhe für mehr Platzierung optimiert.
## v1.6.2 (2026-03-05) ## v1.6.2 (2026-03-05)
-**Feature**: Wochentags-Header (Montag, Dienstag etc.) scrollen nun als "Sticky Header" mit und bleiben am oberen Bildschirmrand haften. -**Feature**: Wochentags-Header (Montag, Dienstag etc.) scrollen nun als "Sticky Header" mit und bleiben am oberen Bildschirmrand haften.
- Das Layout clippt scrollende Speisen ordentlich darunter weg. - Das Layout clippt scrollende Speisen ordentlich darunter weg.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -437,7 +437,7 @@ body {
flex: 1; flex: 1;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
padding: 2rem 0 0 0; padding: 0 0 0 0;
/* Only top padding, no horizontal so child fills width */ /* Only top padding, no horizontal so child fills width */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1095,9 +1095,9 @@ body {
.app-footer { .app-footer {
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
padding: 1rem 2rem; padding: 0.4rem 2rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.8rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
@@ -2123,7 +2123,7 @@ body {
<div class="brand"> <div class="brand">
<span class="material-icons-round logo-icon">restaurant_menu</span> <span class="material-icons-round logo-icon">restaurant_menu</span>
<div class="header-left"> <div class="header-left">
<h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.6.2</small></h1> <h1>Kantinen Übersicht <small class="version-tag" style="font-size: 0.6em; opacity: 0.7; font-weight: 400; cursor: pointer;" title="Klick für Versionsmenü">v1.6.4</small></h1>
<div id="last-updated-subtitle" class="subtitle"></div> <div id="last-updated-subtitle" class="subtitle"></div>
</div> </div>
<div class="nav-group" style="margin-left: 1rem;"> <div class="nav-group" style="margin-left: 1rem;">
@@ -2270,7 +2270,7 @@ body {
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<strong>Aktuell:</strong> <span id="version-current">v1.6.2</span> <strong>Aktuell:</strong> <span id="version-current">v1.6.4</span>
</div> </div>
<div class="dev-toggle"> <div class="dev-toggle">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
@@ -2309,7 +2309,7 @@ body {
</main> </main>
<footer class="app-footer"> <footer class="app-footer">
<p>Bessa Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span></p> <p>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
</footer> </footer>
</div>`; </div>`;
} }
@@ -3350,6 +3350,25 @@ body {
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell(); updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs); if (cachedTs) updateLastUpdatedTime(cachedTs);
// --- TEMP DEBUG LOGGER ---
try {
const uniqueMenus = new Set();
allWeeks.forEach(w => {
(w.days || []).forEach(d => {
(d.items || []).forEach(item => {
let text = (item.description || '').replace(/\s+/g, ' ').trim();
if (text && text.includes(' / ')) {
uniqueMenus.add(text);
}
});
});
});
const res = Array.from(uniqueMenus).join('\n\n');
console.log("=== GEFUNDENE MENÜ-TEXTE (" + uniqueMenus.size + ") ===");
console.log(res);
} catch (e) { }
console.log('Loaded menu from cache'); console.log('Loaded menu from cache');
return true; return true;
} }
@@ -4138,7 +4157,7 @@ body {
// Periodic update check (runs on init + every hour) // Periodic update check (runs on init + every hour)
async function checkForUpdates() { async function checkForUpdates() {
const currentVersion = 'v1.6.2'; const currentVersion = 'v1.6.4';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -4179,7 +4198,7 @@ body {
const modal = document.getElementById('version-modal'); const modal = document.getElementById('version-modal');
const container = document.getElementById('version-list-container'); const container = document.getElementById('version-list-container');
const devToggle = document.getElementById('dev-mode-toggle'); const devToggle = document.getElementById('dev-mode-toggle');
const currentVersion = 'v1.6.2'; const currentVersion = 'v1.6.4';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
@@ -4402,29 +4421,35 @@ body {
// === Language Filter (FR-100) === // === Language Filter (FR-100) ===
// DE stems for fallback language detection // DE stems for fallback language detection
const DE_STEMS = [ const DE_STEMS = [
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe', 'apfel', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst', 'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle', 'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate', 'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch', 'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott',
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier', 'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit',
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust', 'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder',
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine', 'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout',
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot', 'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken',
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere', 'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel',
'plunder', 'dip', 'tofu', 'jambalaya' 'spätzle', 'speck', 'spieß', 'spinat', 'steak', 'suppe', 'süß', 'tofu', 'tomate', 'topfen',
'torte', 'trüffel', 'und', 'vanille', 'vogerl', 'vom', 'wien', 'wurst', 'zucchini', 'zum',
'zur', 'zwiebel', 'öl'
]; ];
const EN_STEMS = [ const EN_STEMS = [
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake', 'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry',
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast', 'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole',
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet', 'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro',
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber', 'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg',
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili', 'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin',
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout', 'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat',
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli', 'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil',
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut', 'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate',
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert', 'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll',
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya' 'salad', 'salami', 'salt', 'sauce', 'sausage', 'shrimp', 'skewer', 'slice', 'soup', 'sour',
'spice', 'spicy', 'spinach', 'steak', 'stew', 'strawberr', 'strawberry', 'strudel', 'sweet', 'tart',
'thyme', 'to', 'tofu', 'tomat', 'tomato', 'truffle', 'trukey', 'turkey', 'vanilla', 'vegan',
'vegetable', 'vinegar', 'wedge', 'wing', 'with', 'wok', 'yogurt', 'zucchini'
]; ];
/** /**
@@ -4438,7 +4463,11 @@ body {
if (!text) return { de: '', en: '', raw: '' }; if (!text) return { de: '', en: '', raw: '' };
const raw = text; const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• '); // Formatting: add • for new lines, using the forgiving regex
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)/g, '($1)\n• ');
if (!formattedRaw.startsWith('• ')) {
formattedRaw = '• ' + formattedRaw;
}
// Utility to compute DE/EN score for a subset of words // Utility to compute DE/EN score for a subset of words
function scoreBlock(wordArray) { function scoreBlock(wordArray) {
@@ -4468,7 +4497,6 @@ body {
} }
// Heuristic sliding window to split a fragment containing "EN DE" // Heuristic sliding window to split a fragment containing "EN DE"
// E.g., "Bratwurst with pumpkin Kirschjoghurt" => enPart: "Bratwurst with pumpkin", dePart: "Kirschjoghurt"
function heuristicSplitEnDe(fragment) { function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/); const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' }; if (words.length < 2) return { enPart: fragment, nextDe: '' };
@@ -4483,22 +4511,20 @@ body {
const leftScore = scoreBlock(left); const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right); const rightScore = scoreBlock(right);
// left should be EN, right should be DE
// Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);
// Extra penalty if the split puts a low-case word as the first word of the right (DE) part
// because a new German sentence usually starts with a capital noun.
const rightFirstWord = right[0]; const rightFirstWord = right[0];
let capitalBonus = 0; let capitalBonus = 0;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) { if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 2.0; capitalBonus = 1.0;
} }
const finalScore = score + capitalBonus; const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
if (finalScore > maxScore) { // Strict condition! The assumed German part must actually look German
maxScore = finalScore; const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k; bestK = k;
} }
} }
@@ -4512,50 +4538,34 @@ body {
return { enPart: fragment, nextDe: '' }; return { enPart: fragment, nextDe: '' };
} }
// Check if text contains the bilingual separator ' / ' // NEW LOGIC: We no longer split by slash if the slash is part of a missing-parenthesis allergen like /ACGL)
if (!text.includes(' / ')) { const parts = text.split(/\s*\/\s*(?![A-Z,]+\))/);
// Fallback: detect language via keyword scoring
const words = text.toLowerCase().split(/\s+/);
const score = scoreBlock(words);
// No split possible return full text for detected language, empty for other
if (score.en > score.de) {
return { de: '', en: formattedRaw, raw: formattedRaw };
}
return { de: formattedRaw, en: '', raw: formattedRaw };
}
// Split by ' / ' produces alternating DE/EN fragments
const parts = text.split(' / ');
// Sanity check: max 3 courses means max 3 slashes → max 4 parts // Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) { if (parts.length > 4) {
// Too many slashes possibly not bilingual, return as-is
return { de: formattedRaw, en: '', raw: formattedRaw }; return { de: formattedRaw, en: '', raw: formattedRaw };
} }
const deParts = []; const deParts = [];
const enParts = []; const enParts = [];
// First fragment is always DE (course 1) // Part 0 is ALWAYS German (beginning of the menu item)
deParts.push(parts[0].trim()); deParts.push(parts[0].trim());
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE" // Matches e.g., "(GLM)" OR "/GLM)" OR " GLM)" with trailing spaces
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary const allergenRegex = /(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*/;
const allergenRegex = /\(([A-Z ]+)\)\s*/;
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim(); const fragment = parts[i].trim();
const match = fragment.match(allergenRegex); const match = fragment.match(allergenRegex);
if (match) { if (match) {
// Split: everything before allergen + allergen = EN, after = next DE
const allergenEnd = match.index + match[0].length; const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim(); const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1]; const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim(); const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')'); enParts.push(enPart + '(' + allergenCode + ')');
// Also append allergen to the last DE part
if (deParts.length > 0) { if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')'; deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
} }
@@ -4564,9 +4574,7 @@ body {
deParts.push(nextDe); deParts.push(nextDe);
} }
} else { } else {
// No allergen code found! // No allergen code found! Need to heuristically split "EN DE"
// If it's not the last part (or even if it is, but we highly suspect merged languages),
// we use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment); const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart); enParts.push(split.enPart);
if (split.nextDe) { if (split.nextDe) {
@@ -4575,9 +4583,27 @@ body {
} }
} }
// FIX FOR SINGLE-LANGUAGE COURSES OR MISSING EN
if (parts.length === 1 && enParts.length === 0) {
enParts.push(deParts[0]);
}
// Mirror untranslated DE courses to EN (e.g. Dessert)
if (deParts.length > enParts.length) {
for (let i = enParts.length; i < deParts.length; i++) {
enParts.push(deParts[i]);
}
}
let deJoined = deParts.join('\n• ');
if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined;
let enJoined = enParts.join('\n• ');
if (enParts.length > 0 && !enJoined.startsWith('• ')) enJoined = '• ' + enJoined;
return { return {
de: deParts.map(p => '• ' + p).join('\n'), de: deJoined,
en: enParts.map(p => '• ' + p).join('\n'), en: enJoined,
raw: formattedRaw raw: formattedRaw
}; };
} }

View File

@@ -268,7 +268,7 @@
</main> </main>
<footer class="app-footer"> <footer class="app-footer">
<p>Bessa Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span></p> <p>Jetzt Bessa Einfach! &bull; Knapp-Kantine Wrapper &bull; <span id="current-year">${new Date().getFullYear()}</span> by Kaufis-Kitchen</p>
</footer> </footer>
</div>`; </div>`;
} }
@@ -1309,6 +1309,25 @@
updateNextWeekBadge(); updateNextWeekBadge();
updateAlarmBell(); updateAlarmBell();
if (cachedTs) updateLastUpdatedTime(cachedTs); if (cachedTs) updateLastUpdatedTime(cachedTs);
// --- TEMP DEBUG LOGGER ---
try {
const uniqueMenus = new Set();
allWeeks.forEach(w => {
(w.days || []).forEach(d => {
(d.items || []).forEach(item => {
let text = (item.description || '').replace(/\s+/g, ' ').trim();
if (text && text.includes(' / ')) {
uniqueMenus.add(text);
}
});
});
});
const res = Array.from(uniqueMenus).join('\n\n');
console.log("=== GEFUNDENE MENÜ-TEXTE (" + uniqueMenus.size + ") ===");
console.log(res);
} catch (e) { }
console.log('Loaded menu from cache'); console.log('Loaded menu from cache');
return true; return true;
} }
@@ -2361,29 +2380,35 @@
// === Language Filter (FR-100) === // === Language Filter (FR-100) ===
// DE stems for fallback language detection // DE stems for fallback language detection
const DE_STEMS = [ const DE_STEMS = [
'mit', 'und', 'oder', 'für', 'vom', 'zum', 'zur', 'gebraten', 'kartoffel', 'gemüse', 'suppe', 'apfel', 'aubergine', 'auflauf', 'beere', 'blumenkohl', 'bohne', 'braten', 'brokkoli', 'brot', 'brust',
'kuchen', 'schwein', 'rind', 'hähnchen', 'huhn', 'fisch', 'nudel', 'soße', 'sosse', 'wurst', 'brötchen', 'butter', 'chili', 'dessert', 'dip', 'eier', 'eintopf', 'eis', 'erbse', 'erdbeer',
'kürbis', 'braten', 'sahne', 'apfel', 'käse', 'fleisch', 'pilz', 'kirsch', 'joghurt', 'spätzle', 'essig', 'filet', 'fisch', 'fisole', 'fleckerl', 'fleisch', 'flügel', 'frucht', 'für', 'gebraten',
'knödel', 'kraut', 'schnitzel', 'püree', 'rahm', 'erdbeer', 'schoko', 'vanille', 'tomate', 'gemüse', 'gewürz', 'gratin', 'grieß', 'gulasch', 'gurke', 'himbeer', 'honig', 'huhn', 'hähnchen',
'gurke', 'salat', 'zwiebel', 'paprika', 'reis', 'bohne', 'erbse', 'karotte', 'möhre', 'lauch', 'jambalaya', 'joghurt', 'karotte', 'kartoffel', 'keule', 'kirsch', 'knacker', 'knoblauch', 'knödel', 'kompott',
'knoblauch', 'chili', 'gewürz', 'kräuter', 'pfeffer', 'salz', 'butter', 'milch', 'eier', 'kraut', 'kräuter', 'kuchen', 'käse', 'kürbis', 'lauch', 'mandel', 'milch', 'mild', 'mit',
'pfanne', 'auflauf', 'gratin', 'ragout', 'gulasch', 'eintopf', 'filet', 'steak', 'brust', 'mohn', 'most', 'möhre', 'natur', 'nockerl', 'nudel', 'nuss', 'nuß', 'obst', 'oder',
'salami', 'schinken', 'speck', 'brokkoli', 'blumenkohl', 'zucchini', 'aubergine', 'olive', 'paprika', 'pfanne', 'pfannkuchen', 'pfeffer', 'pikant', 'pilz', 'plunder', 'püree', 'ragout',
'spinat', 'spargel', 'olive', 'mandel', 'nuss', 'honig', 'senf', 'essig', 'öl', 'brot', 'rahm', 'reis', 'rind', 'sahne', 'salami', 'salat', 'salz', 'sauer', 'scharf', 'schinken',
'brötchen', 'pfannkuchen', 'eis', 'torte', 'dessert', 'kompott', 'obst', 'frucht', 'beere', 'schnitte', 'schnitzel', 'schoko', 'schupf', 'schwein', 'sellerie', 'senf', 'sosse', 'soße', 'spargel',
'plunder', 'dip', 'tofu', 'jambalaya' 'spätzle', 'speck', 'spieß', 'spinat', 'steak', 'suppe', 'süß', 'tofu', 'tomate', 'topfen',
'torte', 'trüffel', 'und', 'vanille', 'vogerl', 'vom', 'wien', 'wurst', 'zucchini', 'zum',
'zur', 'zwiebel', 'öl'
]; ];
const EN_STEMS = [ const EN_STEMS = [
'with', 'and', 'or', 'for', 'from', 'to', 'fried', 'potato', 'vegetable', 'soup', 'cake', 'almond', 'and', 'apple', 'asparagus', 'bacon', 'baked', 'ball', 'bean', 'beef', 'berry',
'pork', 'beef', 'chicken', 'fish', 'noodle', 'sauce', 'sausage', 'pumpkin', 'roast', 'bread', 'breast', 'broccoli', 'bun', 'butter', 'cabbage', 'cake', 'caper', 'carrot', 'casserole',
'cream', 'apple', 'cheese', 'meat', 'mushroom', 'cherry', 'yogurt', 'wedge', 'sweet', 'cauliflower', 'celery', 'cheese', 'cherry', 'chicken', 'chili', 'choco', 'chocolate', 'cider', 'cilantro',
'sour', 'dumpling', 'cabbage', 'mash', 'strawberr', 'choco', 'vanilla', 'tomat', 'cucumber', 'coffee', 'compote', 'cream', 'cucumber', 'curd', 'danish', 'dessert', 'dip', 'dumpling', 'egg',
'salad', 'onion', 'pepper', 'rice', 'bean', 'pea', 'carrot', 'leek', 'garlic', 'chili', 'eggplant', 'filet', 'fish', 'for', 'fried', 'from', 'fruit', 'garlic', 'goulash', 'gratin',
'spice', 'herb', 'salt', 'butter', 'milk', 'egg', 'pan', 'casserole', 'gratin', 'ragout', 'ham', 'herb', 'honey', 'hot', 'ice', 'jambalaya', 'leek', 'leg', 'mash', 'meat',
'goulash', 'stew', 'filet', 'steak', 'breast', 'salami', 'ham', 'bacon', 'broccoli', 'mexican', 'mild', 'milk', 'mint', 'mushroom', 'mustard', 'noodle', 'nut', 'oat', 'oil',
'cauliflower', 'zucchini', 'eggplant', 'spinach', 'asparagus', 'olive', 'almond', 'nut', 'olive', 'onion', 'or', 'oven', 'pan', 'pancake', 'pea', 'pepper', 'plain', 'plate',
'honey', 'mustard', 'vinegar', 'oil', 'bread', 'bun', 'pancake', 'ice', 'tart', 'dessert', 'poppy', 'pork', 'potato', 'pumpkin', 'radish', 'ragout', 'raspberry', 'rice', 'roast', 'roll',
'compote', 'fruit', 'berry', 'dip', 'danish', 'tofu', 'jambalaya' 'salad', 'salami', 'salt', 'sauce', 'sausage', 'shrimp', 'skewer', 'slice', 'soup', 'sour',
'spice', 'spicy', 'spinach', 'steak', 'stew', 'strawberr', 'strawberry', 'strudel', 'sweet', 'tart',
'thyme', 'to', 'tofu', 'tomat', 'tomato', 'truffle', 'trukey', 'turkey', 'vanilla', 'vegan',
'vegetable', 'vinegar', 'wedge', 'wing', 'with', 'wok', 'yogurt', 'zucchini'
]; ];
/** /**
@@ -2397,7 +2422,11 @@
if (!text) return { de: '', en: '', raw: '' }; if (!text) return { de: '', en: '', raw: '' };
const raw = text; const raw = text;
const formattedRaw = '• ' + text.replace(/\(([A-Z ]+)\)\s*(?=\S)/g, '($1)\n• '); // Formatting: add • for new lines, using the forgiving regex
let formattedRaw = text.replace(/(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*(?=\S)/g, '($1)\n• ');
if (!formattedRaw.startsWith('• ')) {
formattedRaw = '• ' + formattedRaw;
}
// Utility to compute DE/EN score for a subset of words // Utility to compute DE/EN score for a subset of words
function scoreBlock(wordArray) { function scoreBlock(wordArray) {
@@ -2427,7 +2456,6 @@
} }
// Heuristic sliding window to split a fragment containing "EN DE" // Heuristic sliding window to split a fragment containing "EN DE"
// E.g., "Bratwurst with pumpkin Kirschjoghurt" => enPart: "Bratwurst with pumpkin", dePart: "Kirschjoghurt"
function heuristicSplitEnDe(fragment) { function heuristicSplitEnDe(fragment) {
const words = fragment.trim().split(/\s+/); const words = fragment.trim().split(/\s+/);
if (words.length < 2) return { enPart: fragment, nextDe: '' }; if (words.length < 2) return { enPart: fragment, nextDe: '' };
@@ -2442,22 +2470,20 @@
const leftScore = scoreBlock(left); const leftScore = scoreBlock(left);
const rightScore = scoreBlock(right); const rightScore = scoreBlock(right);
// left should be EN, right should be DE
// Metric = (EN votes in left - DE votes in left) + (DE votes in right - EN votes in right)
const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en);
// Extra penalty if the split puts a low-case word as the first word of the right (DE) part
// because a new German sentence usually starts with a capital noun.
const rightFirstWord = right[0]; const rightFirstWord = right[0];
let capitalBonus = 0; let capitalBonus = 0;
// Nouns are capitalized in German
if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) { if (/^[A-ZÄÖÜ]/.test(rightFirstWord)) {
capitalBonus = 2.0; capitalBonus = 1.0;
} }
const finalScore = score + capitalBonus; const score = (leftScore.en - leftScore.de) + (rightScore.de - rightScore.en) + capitalBonus;
if (finalScore > maxScore) { // Strict condition! The assumed German part must actually look German
maxScore = finalScore; const rightLooksGerman = (rightScore.de + capitalBonus) > rightScore.en;
if (rightLooksGerman && score > maxScore) {
maxScore = score;
bestK = k; bestK = k;
} }
} }
@@ -2471,50 +2497,34 @@
return { enPart: fragment, nextDe: '' }; return { enPart: fragment, nextDe: '' };
} }
// Check if text contains the bilingual separator ' / ' // NEW LOGIC: We no longer split by slash if the slash is part of a missing-parenthesis allergen like /ACGL)
if (!text.includes(' / ')) { const parts = text.split(/\s*\/\s*(?![A-Z,]+\))/);
// Fallback: detect language via keyword scoring
const words = text.toLowerCase().split(/\s+/);
const score = scoreBlock(words);
// No split possible return full text for detected language, empty for other
if (score.en > score.de) {
return { de: '', en: formattedRaw, raw: formattedRaw };
}
return { de: formattedRaw, en: '', raw: formattedRaw };
}
// Split by ' / ' produces alternating DE/EN fragments
const parts = text.split(' / ');
// Sanity check: max 3 courses means max 3 slashes → max 4 parts // Sanity check: max 3 courses means max 3 slashes → max 4 parts
if (parts.length > 4) { if (parts.length > 4) {
// Too many slashes possibly not bilingual, return as-is
return { de: formattedRaw, en: '', raw: formattedRaw }; return { de: formattedRaw, en: '', raw: formattedRaw };
} }
const deParts = []; const deParts = [];
const enParts = []; const enParts = [];
// First fragment is always DE (course 1) // Part 0 is ALWAYS German (beginning of the menu item)
deParts.push(parts[0].trim()); deParts.push(parts[0].trim());
// Process remaining fragments: each contains "EN(ALLERGENS) next_DE" // Matches e.g., "(GLM)" OR "/GLM)" OR " GLM)" with trailing spaces
// Allergen pattern: (LETTERS_AND_SPACES) at the boundary const allergenRegex = /(?:\(|(?:\/|\s|^))([A-Z,]+)\)\s*/;
const allergenRegex = /\(([A-Z ]+)\)\s*/;
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
const fragment = parts[i].trim(); const fragment = parts[i].trim();
const match = fragment.match(allergenRegex); const match = fragment.match(allergenRegex);
if (match) { if (match) {
// Split: everything before allergen + allergen = EN, after = next DE
const allergenEnd = match.index + match[0].length; const allergenEnd = match.index + match[0].length;
const enPart = fragment.substring(0, match.index).trim(); const enPart = fragment.substring(0, match.index).trim();
const allergenCode = match[1]; const allergenCode = match[1];
const nextDe = fragment.substring(allergenEnd).trim(); const nextDe = fragment.substring(allergenEnd).trim();
enParts.push(enPart + '(' + allergenCode + ')'); enParts.push(enPart + '(' + allergenCode + ')');
// Also append allergen to the last DE part
if (deParts.length > 0) { if (deParts.length > 0) {
deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')'; deParts[deParts.length - 1] = deParts[deParts.length - 1] + '(' + allergenCode + ')';
} }
@@ -2523,9 +2533,7 @@
deParts.push(nextDe); deParts.push(nextDe);
} }
} else { } else {
// No allergen code found! // No allergen code found! Need to heuristically split "EN DE"
// If it's not the last part (or even if it is, but we highly suspect merged languages),
// we use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment); const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart); enParts.push(split.enPart);
if (split.nextDe) { if (split.nextDe) {
@@ -2534,9 +2542,27 @@
} }
} }
// FIX FOR SINGLE-LANGUAGE COURSES OR MISSING EN
if (parts.length === 1 && enParts.length === 0) {
enParts.push(deParts[0]);
}
// Mirror untranslated DE courses to EN (e.g. Dessert)
if (deParts.length > enParts.length) {
for (let i = enParts.length; i < deParts.length; i++) {
enParts.push(deParts[i]);
}
}
let deJoined = deParts.join('\n• ');
if (deParts.length > 0 && !deJoined.startsWith('• ')) deJoined = '• ' + deJoined;
let enJoined = enParts.join('\n• ');
if (enParts.length > 0 && !enJoined.startsWith('• ')) enJoined = '• ' + enJoined;
return { return {
de: deParts.map(p => '• ' + p).join('\n'), de: deJoined,
en: enParts.map(p => '• ' + p).join('\n'), en: enJoined,
raw: formattedRaw raw: formattedRaw
}; };
} }

View File

@@ -426,7 +426,7 @@ body {
flex: 1; flex: 1;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
padding: 2rem 0 0 0; padding: 0 0 0 0;
/* Only top padding, no horizontal so child fills width */ /* Only top padding, no horizontal so child fills width */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1084,9 +1084,9 @@ body {
.app-footer { .app-footer {
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
padding: 1rem 2rem; padding: 0.4rem 2rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.8rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }

View File

@@ -1 +1 @@
v1.6.2 v1.6.4