Compare commits

..

2 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
7 changed files with 166 additions and 149 deletions

View File

@@ -1,3 +1,9 @@
## 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) ## 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. -**Chore**: Slogan im Footer aktualisiert ("Jetzt Bessa Einfach! • Knapp-Kantine Wrapper • 2026 by Kaufis-Kitchen") und Footer-Höhe für mehr Platzierung optimiert.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

19
dist/install.html vendored

File diff suppressed because one or more lines are too long

View File

@@ -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.3</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.3</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;">
@@ -4157,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.3'; const currentVersion = 'v1.6.4';
const devMode = localStorage.getItem('kantine_dev_mode') === 'true'; const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
try { try {
@@ -4198,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.3'; const currentVersion = 'v1.6.4';
if (!modal) return; if (!modal) return;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
@@ -4421,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'
]; ];
/** /**
@@ -4457,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) {
@@ -4487,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: '' };
@@ -4502,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;
} }
} }
@@ -4531,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 + ')';
} }
@@ -4583,25 +4574,36 @@ body {
deParts.push(nextDe); deParts.push(nextDe);
} }
} else { } else {
// No allergen code found! // No allergen code found! Need to heuristically split "EN DE"
// If this is the last fragment, it contains only the English text of the final course. const split = heuristicSplitEnDe(fragment);
// It should not be split again. enParts.push(split.enPart);
if (i === parts.length - 1) { if (split.nextDe) {
enParts.push(fragment); deParts.push(split.nextDe);
} else {
// We use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
} }
} }
} }
// 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

@@ -2380,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'
]; ];
/** /**
@@ -2416,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) {
@@ -2446,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: '' };
@@ -2461,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;
} }
} }
@@ -2490,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 + ')';
} }
@@ -2542,25 +2533,36 @@
deParts.push(nextDe); deParts.push(nextDe);
} }
} else { } else {
// No allergen code found! // No allergen code found! Need to heuristically split "EN DE"
// If this is the last fragment, it contains only the English text of the final course. const split = heuristicSplitEnDe(fragment);
// It should not be split again. enParts.push(split.enPart);
if (i === parts.length - 1) { if (split.nextDe) {
enParts.push(fragment); deParts.push(split.nextDe);
} else {
// We use the heuristic to find the hidden split-point.
const split = heuristicSplitEnDe(fragment);
enParts.push(split.enPart);
if (split.nextDe) {
deParts.push(split.nextDe);
}
} }
} }
} }
// 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

@@ -1 +1 @@
v1.6.3 v1.6.4