fix(search): robust Czech fulltext search engine with stemming, multi-token matching and highlight

This commit is contained in:
Krystof Pesek
2026-11-07 19:27:30 +01:00
parent a4d7934905
commit 111f7537f4
5 changed files with 2257 additions and 2634 deletions
+480 -75
View File
@@ -29,12 +29,12 @@ function playSpeaker(freq = 800, duration = 0.04, type = 'square') {
} catch (e) {}
}
// Data references
// Data references loaded from data.js
const KNIHA_RECEPTU = window.KNIHA_RECEPTU || [];
const HISTORICKA_JIDLA = window.HISTORICKA_JIDLA || [];
// App State
let currentView = 'menu'; // 'menu', 'recept', 'recepty_list', 'suroviny', 'davno', 'oblibena', 'kronika', 'altan'
let currentView = 'menu'; // 'menu', 'recept', 'recepty_list', 'hledat', 'suroviny', 'davno', 'oblibena', 'kronika', 'altan'
let currentRecipeId = 1;
let currentCategory = 'Vše';
let listPage = 0;
@@ -50,11 +50,163 @@ setInterval(() => {
timeEl.textContent = d.toTimeString().split(' ')[0];
}, 1000);
// Normalization function (strip accents & non-alphanumeric)
// =========================================================================
// CZECH TEXT NORMALIZATION & FULLTEXT SEARCH ENGINE
// =========================================================================
// Normalizace: malá písmena, odstranění diakritiky
function norm(str) {
return (str || "").toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]/g, "");
.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
// Český lehký stemmer (odstranění pádových a slovesných koncovek pro dokonalou flexi)
function stem(word) {
let w = norm(word).replace(/[^a-z0-9]/g, "");
if (w.length <= 3) return w;
// Dlouhé přídavné a slovesné koncovky
w = w.replace(/(ovy|ova|ove|ymi|ych|ich|ovi|ami|ach|ech|eho|emu|ajici|ejici)$/, "");
if (w.length > 3) {
// Slovesa a podstatná jména
w = w.replace(/(ime|ite|eme|ete|aji|eji|ala|alo|ali|ila|ilo|ili|ula|ulo|uli)$/, "");
}
if (w.length > 3) {
// Dvojpísmenné koncovky
w = w.replace(/(em|ou|am|um|ym|im|ky|ka|ku|ek|ce|ci|ho|mu|mi|es|as|at|et|it|ut|ti|al|il|ul|ne|na|ny)$/, "");
}
if (w.length > 3) {
// Krátké samohlásky
w = w.replace(/[aeiouy]$/, "");
}
return w;
}
// Rozdělení dotazu na čistá slova
function tokenize(str) {
return norm(str)
.split(/[\s,.;:()!?"'\/\\+*#%&<=>[\]{}|~_`^$]+/)
.filter(t => t.length > 0);
}
// Kontrola shody slova:
function wordMatches(targetWord, queryWord, queryStem) {
const tw = norm(targetWord);
if (tw === queryWord) return true;
if (tw.startsWith(queryWord) || queryWord.startsWith(tw)) return true;
const ts = stem(targetWord);
if (ts === queryStem) return true;
if (queryStem.length >= 3 && ts.startsWith(queryStem)) return true;
if (ts.length >= 3 && queryStem.startsWith(ts)) return true;
return false;
}
// Zvýraznění shod v textu (HTML mark)
function highlightMatches(text, query) {
if (!query || !text) return text;
const tokens = tokenize(query).filter(t => t.length > 1);
if (tokens.length === 0) return text;
const stems = tokens.map(stem);
const parts = text.split(/(\s+|[.,;()\-+*\/:"'!?<>])/);
return parts.map(part => {
if (!part.trim()) return part;
const np = norm(part);
const sp = stem(part);
const isMatch = tokens.some((tok, idx) => wordMatches(part, tok, stems[idx]));
if (isMatch) {
return `<mark class="match-highlight">${part}</mark>`;
}
return part;
}).join("");
}
// Robustní vyhledávání v sešitu Kamily Pešek
function searchKniha(query, categoryFilter = 'Vše') {
if (!query || !query.trim()) return [];
const tokens = tokenize(query);
if (tokens.length === 0) return [];
const stems = tokens.map(stem);
const source = categoryFilter === 'Vše'
? KNIHA_RECEPTU
: KNIHA_RECEPTU.filter(r => r.kategorie === categoryFilter);
const results = [];
source.forEach(r => {
const titleWords = tokenize(r.nazev);
const surWords = tokenize((r.suroviny || []).join(" "));
const posWords = tokenize(r.postup || "");
const katWords = tokenize(r.kategorie || "");
let score = 0;
let matchedTokens = 0;
let snippet = "";
tokens.forEach((tok, idx) => {
const st = stems[idx];
let hit = false;
// 1. Shoda v názvu (priorita #1)
if (titleWords.some(tw => wordMatches(tw, tok, st))) {
score += 150;
hit = true;
}
// 2. Shoda v surovinách
if (surWords.some(sw => wordMatches(sw, tok, st))) {
score += 50;
hit = true;
if (!snippet) {
const found = (r.suroviny || []).find(s => tokenize(s).some(sw => wordMatches(sw, tok, st)));
if (found) snippet = "Surovina: " + found;
}
}
// 3. Shoda v kategorii
if (katWords.some(kw => wordMatches(kw, tok, st))) {
score += 25;
hit = true;
}
// 4. Shoda v postupu
if (posWords.some(pw => wordMatches(pw, tok, st))) {
score += 20;
hit = true;
if (!snippet && r.postup) {
snippet = "Postup: " + r.postup.substring(0, 110) + "...";
}
}
if (hit) matchedTokens++;
});
// Masivní bonus za pokrytí všech hledaných slov (AND logika)
if (matchedTokens === tokens.length) {
score += 500 * tokens.length;
}
if (score > 0) {
results.push({
recipe: r,
score: score,
matchedCount: matchedTokens,
snippet: snippet
});
}
});
// Seřadit: nejdřív ty, co splňují všechny tokeny, pak podle skóre
results.sort((a, b) => {
const fullA = a.matchedCount === tokens.length ? 1 : 0;
const fullB = b.matchedCount === tokens.length ? 1 : 0;
if (fullA !== fullB) return fullB - fullA;
return b.score - a.score || a.recipe.id - b.recipe.id;
});
return results;
}
// Categories list
@@ -66,14 +218,19 @@ const KATEGORIE = [
'Moučníky a sladké',
'Přílohy a bezmasá',
'Omáčky',
'Saláty a zelenina'
'Saláty a zelenina',
'Ostatní'
];
// Views renderers
// =========================================================================
// VIEWS & CONTROLLERS
// =========================================================================
// 1. HLAVNÍ NABÍDKA
function renderMainMenu() {
currentView = 'menu';
winTitleEl.textContent = "KUCHAŘKA KAMILY PEŠEK // HLAVNÍ NABÍDKA PASCAL 7.0";
let html = `
<div class="dos-header">════════════════════════════════════════════════════════════════════════════════
KUCHAŘKA KAMILY PEŠEK // AUTENTICKÝ SEŠIT RECEPTŮ (1998–2026)
@@ -86,19 +243,30 @@ function renderMainMenu() {
sešitu (strany 1–43) a <strong>${HISTORICKA_JIDLA.length} záznamů rodinného jídelníčku</strong> z let 1998–2021.
</div>
<div class="dos-header" style="color:var(--dos-cyan)">── RYCHLÉ VYHLEDÁVÁNÍ V KUCHAŘCE ───────────────────────────────────────────</div>
<div class="dos-input-group" style="margin:6px 0 12px 0">
<span style="color:var(--dos-yellow); font-weight:bold">HLEDAT:</span>
<input type="text" id="menu-quick-search" class="dos-input" placeholder="Napište název, surovinu či postup (např. kuře, bábovka, bramborový salát, česnek)...">
<button class="dos-btn" onclick="triggerMenuSearch()">HLEDAT [ENTER]</button>
</div>
<div class="dos-header" style="color:var(--dos-cyan)">── HLAVNÍ PROGRAMOVÉ MODULY ────────────────────────────────────────────────</div>
<div class="dos-menu-list">
<div class="dos-menu-item" onclick="openSearch()">
<span class="hotkey">[H / /]</span>
<span style="color:var(--dos-yellow); font-weight:bold">>> FULLTEXTOVÉ HLEDÁNÍ V CELÉM SEŠITU (${KNIHA_RECEPTU.length} receptů) <<</span>
</div>
<div class="dos-menu-item" onclick="openReceptyList('Vše')">
<span class="hotkey">[R]</span>
<span style="color:var(--dos-white); font-weight:bold">>> PROCHÁZET CELÝ RECEPTÁŘ KAMILY PEŠEK (${KNIHA_RECEPTU.length} receptů dle kategorií) <<</span>
<span style="color:var(--dos-white); font-weight:bold">Procházet receptář Kamily Pešek podle kategorií</span>
</div>
<div class="dos-menu-item" onclick="openSuroviny()">
<span class="hotkey">[S]</span>
<span style="color:var(--dos-yellow); font-weight:bold">>> VAŘIT PODLE SUROVIN VE ŠPAJZU << (Algoritmus prohledání lednice)</span>
<span style="color:var(--dos-green); font-weight:bold">Vařit podle surovin ve špajzu a lednici (Algoritmus KUCHARKA.EXE)</span>
</div>
<div class="dos-menu-item" onclick="openRandomRecipe()">
<span class="hotkey">[N]</span>
<span style="color:var(--dos-green)">Náhodný recept pro dnešní den (Inspirace z Kamilina sešitu)</span>
<span>Náhodný recept pro dnešní den (Inspirace ze sešitu)</span>
</div>
<div class="dos-menu-item" onclick="openDavno()">
<span class="hotkey">[D]</span>
@@ -110,7 +278,7 @@ function renderMainMenu() {
</div>
<div class="dos-menu-item" onclick="openKronika()">
<span class="hotkey">[K]</span>
<span>Kronika rodinného stolu (Prohlížeč ${HISTORICKA_JIDLA.length} záznamů z Bohnic a Dejvic)</span>
<span>Kronika rodinného stolu (${HISTORICKA_JIDLA.length} záznamů Bohnice a Dejvice)</span>
</div>
<div class="dos-menu-item" onclick="openAltan()">
<span class="hotkey">[A]</span>
@@ -118,8 +286,8 @@ function renderMainMenu() {
</div>
</div>
<div class="dos-header" style="color:var(--dos-cyan)">── RYCHLÝ VÝBĚR KATEGORIE ──────────────────────────────────────────────────</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin:10px 0">
<div class="dos-header" style="color:var(--dos-cyan)">── RYCHLÝ VÝBĚR DLE KATEGORIE ──────────────────────────────────────────────</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin:8px 0">
`;
KATEGORIE.slice(1).forEach((cat, idx) => {
@@ -129,32 +297,186 @@ function renderMainMenu() {
html += `</div>
<div style="margin-top:20px; color:var(--dos-grey)">
Zvolte klávesu <span class="hotkey">[R, S, N, D, O, K, A]</span> nebo kategorii <span class="hotkey">[1-7]</span>. Na mobilu klepněte prstem.
<div style="margin-top:14px; color:var(--dos-grey)">
Zvolte klávesu <span class="hotkey">[H, R, S, N, D, O, K, A, /]</span> nebo kategorii <span class="hotkey">[1-8]</span>. Na mobilu klepněte prstem.
</div>
`;
screenEl.innerHTML = html;
const quickInput = document.getElementById('menu-quick-search');
if (quickInput) {
quickInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') triggerMenuSearch();
});
}
}
// List of recipes with category filter & pagination
function openReceptyList(category = 'Vše', page = 0) {
function triggerMenuSearch() {
const input = document.getElementById('menu-quick-search');
if (input && input.value.trim()) {
openSearch(input.value.trim());
} else {
openSearch();
}
}
// 2. FULLTEXTOVÉ VYHLEDÁVÁNÍ (DEDIKOVANÉ ROZHRANÍ)
function openSearch(defaultQuery = "") {
currentView = 'hledat';
playSpeaker(750, 0.05);
winTitleEl.textContent = `HLEDÁNÍ V KUCHAŘCE KAMILY PEŠEK // FULLTEXT ENGINE`;
let html = `
<div class="dos-header">════════════════════════════════════════════════════════════════════════════════
FULLTEXTOVÉ VYHLEDÁVÁNÍ // RECEPTÁŘ KAMILY PEŠEK (${KNIHA_RECEPTU.length} RECEPTŮ)
════════════════════════════════════════════════════════════════════════════════</div>
<div class="dos-box">
Zadejte hledaný název jídla, surovinu nebo kousek postupu (např. <em>kuře, bábovka, kmínová, guláš, česnek, ořechy, brambory</em>).
Vyhledávač automaticky ohýbá česká slova a prohledává všech <strong>${KNIHA_RECEPTU.length} receptů ze sešitu</strong>.
</div>
<div class="dos-input-group" style="margin:10px 0 12px 0">
<span style="color:var(--dos-yellow); font-weight:bold">HLEDAT V RECEPTÁŘI:</span>
<input type="text" id="global-search-input" class="dos-input" placeholder="Zadejte hledané slovo..." value="${defaultQuery}">
<button class="dos-btn" onclick="executeGlobalSearch()">HLEDAT [ENTER]</button>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px">
<span style="color:var(--dos-cyan); align-self:center; font-size:13px">Časté dotazy:</span>
<button class="dos-btn" onclick="quickQuery('kuře')">kuře</button>
<button class="dos-btn" onclick="quickQuery('brambory')">brambory</button>
<button class="dos-btn" onclick="quickQuery('bábovka')">bábovka</button>
<button class="dos-btn" onclick="quickQuery('polévka')">polévka</button>
<button class="dos-btn" onclick="quickQuery('guláš')">guláš</button>
<button class="dos-btn" onclick="quickQuery('salát')">salát</button>
<button class="dos-btn" onclick="quickQuery('cukroví')">cukroví</button>
<button class="dos-btn" onclick="quickQuery('česnek')">česnek</button>
<button class="dos-btn" onclick="quickQuery('tvaroh')">tvaroh</button>
</div>
<div id="global-search-results">
<!-- Dynamické výsledky hledání -->
</div>
<div style="margin-top:20px; display:flex; gap:10px">
<button class="dos-btn" onclick="renderMainMenu()">[ESC] ZPĚT DO MENU</button>
<button class="dos-btn" onclick="openReceptyList('Vše')">[R] CELÝ SEZNAM RECEPTŮ</button>
</div>
`;
screenEl.innerHTML = html;
const input = document.getElementById('global-search-input');
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
input.addEventListener('input', () => {
executeGlobalSearch();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') executeGlobalSearch();
});
if (defaultQuery) {
executeGlobalSearch();
}
}
function quickQuery(q) {
const input = document.getElementById('global-search-input');
if (input) {
input.value = q;
executeGlobalSearch();
input.focus();
}
}
function executeGlobalSearch() {
const input = document.getElementById('global-search-input');
const resEl = document.getElementById('global-search-results');
if (!input || !resEl) return;
const query = input.value.trim();
if (!query) {
resEl.innerHTML = `<div style="color:var(--dos-grey); padding:10px">Zadejte alespoň 2 písmena pro vyhledání v sešitu Kamily Pešek...</div>`;
return;
}
playSpeaker(850, 0.03);
const results = searchKniha(query, 'Vše');
let html = "";
if (results.length > 0) {
html += `<div style="color:var(--dos-yellow); margin-bottom:8px; font-weight:bold">
★ Nalezeno ${results.length} receptů pro dotaz "${query}":
</div>`;
html += `<div class="dos-menu-list">`;
results.slice(0, 20).forEach(item => {
const r = item.recipe;
const highlightedTitle = highlightMatches(r.nazev, query);
const highlightedSnippet = item.snippet ? highlightMatches(item.snippet, query) : "";
html += `
<div class="dos-menu-item" onclick="openRecipe(${r.id})" style="flex-direction:column; align-items:flex-start">
<div style="display:flex; width:100%; justify-content:space-between; align-items:center">
<span>
<span class="hotkey">[${r.id}]</span>
<span style="color:var(--dos-white); font-weight:bold">${highlightedTitle}</span>
</span>
<span class="tag-badge">Str. ${r.strana} | ${r.kategorie}</span>
</div>
${highlightedSnippet ? `<div class="search-result-snippet">${highlightedSnippet}</div>` : ''}
</div>`;
});
html += `</div>`;
if (results.length > 20) {
html += `<div style="color:var(--dos-grey); margin-top:8px; font-size:13px">...a dalších ${results.length - 20} receptů. Upřesněte dotaz pro zúžení výběru.</div>`;
}
} else {
html += `<div style="color:var(--dos-red); padding:12px 0">
Pro dotaz "${query}" nebyl v sešitu Kamily Pešek nalezen žádný recept.
</div>`;
// Prohledat i kroniku 568 jídel jako zálohu
const histMatches = HISTORICKA_JIDLA.filter(j => norm(j.jidlo).includes(norm(query)));
if (histMatches.length > 0) {
html += `<div style="color:var(--dos-cyan); margin:12px 0 6px 0; font-weight:bold">
📜 Nalezeno ale v Kronice rodinného stolu (1998–2021):
</div>`;
histMatches.slice(0, 8).forEach(j => {
html += `<div style="padding:3px 8px; border-left:2px solid var(--dos-cyan); margin:3px 0">
<span style="color:var(--dos-white)">${j.jidlo}</span>
<span style="color:var(--dos-grey)"> [${j.datum}, ${j.typ}]</span>
</div>`;
});
}
}
resEl.innerHTML = html;
}
// 3. SEZNAM RECEPTŮ PODLE KATEGORIÍ S INTEGROVANÝM VYHLEDÁVÁNÍM
function openReceptyList(category = 'Vše', page = 0, initialQuery = "") {
currentView = 'recepty_list';
currentCategory = category;
listPage = page;
playSpeaker(700, 0.04);
const filtered = category === 'Vše' ? KNIHA_RECEPTU : KNIHA_RECEPTU.filter(r => r.kategorie === category);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const baseList = category === 'Vše' ? KNIHA_RECEPTU : KNIHA_RECEPTU.filter(r => r.kategorie === category);
const totalPages = Math.ceil(baseList.length / PAGE_SIZE);
const pageItems = baseList.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
winTitleEl.textContent = `RECEPTÁŘ KAMILY PEŠEK // ${category.toUpperCase()} (${filtered.length} RECEPTŮ)`;
winTitleEl.textContent = `RECEPTÁŘ KAMILY PEŠEK // ${category.toUpperCase()} (${baseList.length} RECEPTŮ)`;
let html = `
<div class="dos-header">════════════════════════════════════════════════════════════════════════════════
RECEPTÁŘ KAMILY PEŠEK // KATEGORIE: ${category.toUpperCase()} (Strana ${page + 1}/${totalPages || 1})
════════════════════════════════════════════════════════════════════════════════</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px">
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px">
`;
KATEGORIE.forEach(cat => {
const activeClass = cat === category ? 'style="background-color:var(--dos-yellow); color:var(--dos-black)"' : '';
@@ -163,14 +485,17 @@ function openReceptyList(category = 'Vše', page = 0) {
html += `</div>
<div class="dos-input-group" style="margin:8px 0 14px 0">
<input type="text" id="recipe-search-input" class="dos-input" placeholder="Rychlé hledání v receptech (např. bábovka, kuře, kmínová, závin)...">
<div class="dos-input-group" style="margin:6px 0 12px 0">
<input type="text" id="recipe-search-input" class="dos-input" placeholder="Rychlé hledání v receptech (např. bábovka, kuře, brambory, kmínová)..." value="${initialQuery}">
<button class="dos-btn" onclick="clearRecipeSearch('${category}')">ZRUŠIT FILTR</button>
</div>
<div id="search-status-bar" style="margin-bottom:8px; font-size:13px; color:var(--dos-yellow)"></div>
<div class="dos-menu-list" id="recipe-items-container">
`;
pageItems.forEach((r, idx) => {
pageItems.forEach((r) => {
html += `<div class="dos-menu-item" onclick="openRecipe(${r.id})">
<span class="hotkey">[${r.id}]</span>
<span style="color:var(--dos-white)">${r.nazev}</span>
@@ -180,14 +505,14 @@ function openReceptyList(category = 'Vše', page = 0) {
html += `</div>
<div style="margin-top:16px; display:flex; justify-content:space-between; align-items:center">
<div id="recipe-pagination" style="margin-top:16px; display:flex; justify-content:space-between; align-items:center">
<div style="display:flex; gap:10px">
<button class="dos-btn" onclick="renderMainMenu()">[ESC] HLAVNÍ NABÍDKA</button>
${page > 0 ? `<button class="dos-btn" onclick="openReceptyList('${category}', ${page - 1})">[◄] PŘEDCHOZÍ STRANA</button>` : ''}
${page < totalPages - 1 ? `<button class="dos-btn" onclick="openReceptyList('${category}', ${page + 1})">[►] DALŠÍ STRANA</button>` : ''}
</div>
<div style="color:var(--dos-yellow); font-size:13px">
Celkem ${filtered.length} receptů
Celkem ${baseList.length} receptů v této kategorii
</div>
</div>
`;
@@ -195,29 +520,67 @@ function openReceptyList(category = 'Vše', page = 0) {
// Search input live filtering
const searchInput = document.getElementById('recipe-search-input');
searchInput.addEventListener('input', (e) => {
const q = norm(e.target.value);
const container = document.getElementById('recipe-items-container');
const pagination = document.getElementById('recipe-pagination');
const statusBar = document.getElementById('search-status-bar');
const handleSearch = () => {
const q = searchInput.value.trim();
if (!q) {
openReceptyList(category, page);
statusBar.innerHTML = "";
pagination.style.display = "flex";
let defaultHtml = "";
pageItems.forEach((r) => {
defaultHtml += `<div class="dos-menu-item" onclick="openRecipe(${r.id})">
<span class="hotkey">[${r.id}]</span>
<span style="color:var(--dos-white)">${r.nazev}</span>
<span class="tag-badge" style="margin-left:auto">Str. ${r.strana} | ${r.kategorie}</span>
</div>`;
});
container.innerHTML = defaultHtml;
return;
}
const matched = filtered.filter(r => norm(r.nazev).includes(q) || norm(r.postup).includes(q) || r.suroviny.some(s => norm(s).includes(q)));
const container = document.getElementById('recipe-items-container');
// Hledat primárně v celé knize, pokud uživatel píše dotaz
const matched = searchKniha(q, 'Vše');
pagination.style.display = "none";
statusBar.innerHTML = `Nalezeno <strong>${matched.length} receptů</strong> v celém sešitu pro dotaz "<em>${q}</em>":`;
let mHtml = "";
matched.slice(0, 15).forEach(r => {
mHtml += `<div class="dos-menu-item" onclick="openRecipe(${r.id})">
<span class="hotkey">[${r.id}]</span>
<span style="color:var(--dos-white)">${r.nazev}</span>
<span class="tag-badge" style="margin-left:auto">Str. ${r.strana} | ${r.kategorie}</span>
matched.slice(0, 25).forEach(item => {
const r = item.recipe;
const highlightedTitle = highlightMatches(r.nazev, q);
const snippet = item.snippet ? highlightMatches(item.snippet, q) : "";
mHtml += `<div class="dos-menu-item" onclick="openRecipe(${r.id})" style="flex-direction:column; align-items:flex-start">
<div style="display:flex; width:100%; justify-content:space-between; align-items:center">
<span>
<span class="hotkey">[${r.id}]</span>
<span style="color:var(--dos-white); font-weight:bold">${highlightedTitle}</span>
</span>
<span class="tag-badge">Str. ${r.strana} | ${r.kategorie}</span>
</div>
${snippet ? `<div class="search-result-snippet">${snippet}</div>` : ''}
</div>`;
});
if (matched.length === 0) {
mHtml = `<div style="color:var(--dos-red); padding:10px">Žádný recept neodpovídá hledanému výrazu "${e.target.value}".</div>`;
mHtml = `<div style="color:var(--dos-red); padding:10px">Žádný recept v sešitu neodpovídá hledanému výrazu "${q}".</div>`;
}
container.innerHTML = mHtml;
});
};
searchInput.addEventListener('input', handleSearch);
if (initialQuery) {
handleSearch();
}
}
function clearRecipeSearch(category) {
openReceptyList(category, 0);
}
// 4. DETAIL RECEPTU
function openRecipe(id) {
const r = KNIHA_RECEPTU.find(x => x.id === id);
if (!r) return;
@@ -236,15 +599,19 @@ function openRecipe(id) {
<div class="dos-header" style="color:var(--dos-cyan)">── POTŘEBNÉ SUROVINY ZE SEŠITU ─────────────────────────────────────────────</div>
<ul style="list-style:none; padding-left:10px; margin-bottom:15px">
`;
r.suroviny.forEach(s => {
html += `<li style="padding:2px 0">• <span style="color:var(--dos-yellow)">${s}</span></li>`;
});
if (r.suroviny && r.suroviny.length > 0) {
r.suroviny.forEach(s => {
html += `<li style="padding:2px 0">• <span style="color:var(--dos-yellow)">${s}</span></li>`;
});
} else {
html += `<li style="padding:2px 0; color:var(--dos-grey)">• Běžné základní suroviny dle chuti a zvyklostí</li>`;
}
html += `</ul>
<div class="dos-header" style="color:var(--dos-cyan)">── POSTUP PŘÍPRAVY ZE SEŠITU KAMILY PEŠEK ─────────────────────────────────</div>
<div class="dos-box" style="line-height:1.55; color:var(--dos-white)">
${r.postup}
${r.postup || "Tradiční rodinný postup podle zápisu Kamily Pešek."}
</div>
<div class="secret-badge">
@@ -254,6 +621,7 @@ ${r.postup}
<div style="margin-top:20px; display:flex; gap:10px; flex-wrap:wrap">
<button class="dos-btn" onclick="openReceptyList(currentCategory, listPage)">[ESC] ZPĚT DO SEZNAMU</button>
<button class="dos-btn" onclick="openSearch()">[/] HLEDAT JINÝ</button>
<button class="dos-btn" onclick="renderMainMenu()">[H] HLAVNÍ NABÍDKA</button>
${id > 1 ? `<button class="dos-btn" onclick="openRecipe(${id - 1})">[◄] PŘEDCHOZÍ (${id - 1})</button>` : ''}
${id < KNIHA_RECEPTU.length ? `<button class="dos-btn" onclick="openRecipe(${id + 1})">[►] DALŠÍ (${id + 1})</button>` : ''}
@@ -262,11 +630,14 @@ ${r.postup}
screenEl.innerHTML = html;
}
// 5. NÁHODNÝ RECEPT
function openRandomRecipe() {
if (KNIHA_RECEPTU.length === 0) return;
const randomId = Math.floor(Math.random() * KNIHA_RECEPTU.length) + 1;
openRecipe(randomId);
}
// 6. VAŘENÍ ZE SUROVIN VE ŠPAJZU (ALGORITMUS)
function openSuroviny(defaultQuery = "") {
currentView = 'suroviny';
playSpeaker(700, 0.06);
@@ -287,6 +658,15 @@ function openSuroviny(defaultQuery = "") {
<button class="dos-btn" onclick="doSearchSuroviny()">HLEDAT [ENTER]</button>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap; margin:10px 0">
<span style="color:var(--dos-cyan); align-self:center; font-size:13px">Rychlé koše:</span>
<button class="dos-btn" onclick="fillPantry('kuře, máslo, česnek')">Kuře + česnek</button>
<button class="dos-btn" onclick="fillPantry('brambory, cibule, vejce')">Brambory + vejce</button>
<button class="dos-btn" onclick="fillPantry('tvaroh, mouka, cukr')">Tvaroh + mouka</button>
<button class="dos-btn" onclick="fillPantry('jablka, skořice, máslo')">Jablka + skořice</button>
<button class="dos-btn" onclick="fillPantry('mleté maso, cibule, česnek')">Mleté maso</button>
</div>
<div id="vysledky-hledani">
<!-- Dynamic match results -->
</div>
@@ -300,9 +680,7 @@ function openSuroviny(defaultQuery = "") {
const input = document.getElementById('suroviny-input');
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
doSearchSuroviny();
}
if (e.key === 'Enter') doSearchSuroviny();
});
if (defaultQuery) {
@@ -310,6 +688,14 @@ function openSuroviny(defaultQuery = "") {
}
}
function fillPantry(str) {
const input = document.getElementById('suroviny-input');
if (input) {
input.value = str;
doSearchSuroviny();
}
}
function doSearchSuroviny() {
const query = document.getElementById('suroviny-input').value.trim();
const resEl = document.getElementById('vysledky-hledani');
@@ -319,29 +705,31 @@ function doSearchSuroviny() {
}
playSpeaker(1000, 0.04);
const tokens = query.toLowerCase().split(/[\s,]+/).filter(t => t.length > 1).map(norm);
const tokens = tokenize(query);
const stems = tokens.map(stem);
// Score Recipes (both suroviny and postup)
// Score Recipes (suroviny, nazev, postup)
const scoredRecipes = KNIHA_RECEPTU.map(r => {
let matches = [];
const fullText = (r.suroviny.join(' ') + ' ' + r.postup).toLowerCase();
const nFull = norm(fullText);
const normFull = norm(r.nazev + " " + (r.suroviny || []).join(" ") + " " + (r.postup || ""));
tokens.forEach(tok => {
if (nFull.includes(tok)) {
tokens.forEach((tok, idx) => {
const st = stems[idx];
if (normFull.includes(tok) || (st.length >= 3 && normFull.includes(st))) {
matches.push(tok);
}
});
matches = [...new Set(matches)];
return { recipe: r, score: matches.length, matchedTokens: matches };
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score);
}).filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.recipe.id - b.recipe.id);
// Score Historical Database
const scoredHist = HISTORICKA_JIDLA.map(j => {
let matches = [];
const nJidlo = norm(j.jidlo);
tokens.forEach(tok => {
if (nJidlo.includes(tok)) {
tokens.forEach((tok, idx) => {
const st = stems[idx];
if (nJidlo.includes(tok) || (st.length >= 3 && nJidlo.includes(st))) {
matches.push(tok);
}
});
@@ -364,7 +752,7 @@ function doSearchSuroviny() {
if (scoredHist.length > 0) {
html += `<div style="color:var(--dos-cyan); margin:15px 0 5px 0; font-weight:bold">📜 ZÁZNAMY Z HISTORICKÉHO JÍDELNÍČKU (1998–2021):</div>`;
scoredHist.slice(0, 10).forEach(item => {
scoredHist.slice(0, 8).forEach(item => {
html += `<div style="padding:3px 8px; border-left:2px solid var(--dos-cyan); margin:4px 0">
<span style="color:var(--dos-white)">${item.jidlo.jidlo}</span>
<span style="color:var(--dos-grey)"> [${item.jidlo.datum}, ${item.jidlo.typ}]</span>
@@ -380,6 +768,7 @@ function doSearchSuroviny() {
resEl.innerHTML = html;
}
// 7. DÁVNO NEVAŘENO
function openDavno() {
currentView = 'davno';
playSpeaker(600, 0.05);
@@ -412,12 +801,12 @@ function openDavno() {
screenEl.innerHTML = html;
}
// 8. OBLÍBENÁ JÍDLA
function openOblibena() {
currentView = 'oblibena';
playSpeaker(850, 0.05);
winTitleEl.textContent = "OBLÍBENÁ JÍDLA // ŽELEZNÁ STATISTIKA STOLU";
// Count occurrences of keywords in historical records
const freq = {};
HISTORICKA_JIDLA.forEach(j => {
const title = j.jidlo.split(',')[0].trim();
@@ -456,10 +845,11 @@ function openOblibena() {
screenEl.innerHTML = html;
}
// 9. KRONIKA RODINNÉHO STOLU
function openKronika(page = 0) {
currentView = 'kronika';
playSpeaker(650, 0.05);
winTitleEl.textContent = `KRONIKA RODINNÉHO STOLU // ${HISTORICKA_JIDLA.length} ZÁZNAMŮ BOHNICE (1998–2021)`;
playSpeaker(700, 0.04);
winTitleEl.textContent = `KRONIKA STOLU BOHNICE & DEJVICE (${HISTORICKA_JIDLA.length} ZÁZNAMŮ)`;
const totalPages = Math.ceil(HISTORICKA_JIDLA.length / 20);
const items = HISTORICKA_JIDLA.slice(page * 20, (page + 1) * 20);
@@ -468,31 +858,37 @@ function openKronika(page = 0) {
<div class="dos-header">════════════════════════════════════════════════════════════════════════════════
KRONIKA RODINNÉHO STOLU // BOHNICE & DEJVICE (Strana ${page + 1}/${totalPages})
════════════════════════════════════════════════════════════════════════════════</div>
<div class="dos-box">
Celkem ${HISTORICKA_JIDLA.length} autentických záznamů z databázových souborů JIDLA.TXT a JIDLA-bohnice.TXT.
Od srpna 1998, přes dětská léta až po rok 2021.
Kompletní záznamy o tom, co se skutečně vařilo a jedlo v rodině Kamily Pešek v letech 1998–2021.
</div>
<div style="max-height:360px; overflow-y:auto; border:1px solid var(--dos-cyan); padding:8px">
<div class="dos-menu-list">
`;
items.forEach(j => {
html += `<div style="padding:3px 0; border-bottom:1px dotted rgba(0,170,170,0.3)">
<span style="color:var(--dos-yellow); font-weight:bold; width:95px; display:inline-block">${j.datum}</span>
<span class="tag-badge">[${j.typ}]</span>
<span style="color:var(--dos-white); margin-left:6px">${j.jidlo}</span>
items.forEach((j) => {
html += `<div style="padding:4px 8px; border-bottom:1px solid #002244; display:flex; justify-content:space-between">
<span><strong style="color:var(--dos-yellow)">${j.datum}:</strong> <span style="color:var(--dos-white)">${j.jidlo}</span></span>
<span class="tag-badge">${j.typ}</span>
</div>`;
});
html += `</div>
<div style="margin-top:16px; display:flex; gap:10px">
<button class="dos-btn" onclick="renderMainMenu()">[ESC] ZPĚT DO MENU</button>
${page > 0 ? `<button class="dos-btn" onclick="openKronika(${page - 1})">[◄] PŘEDCHOZÍ (20)</button>` : ''}
${page < totalPages - 1 ? `<button class="dos-btn" onclick="openKronika(${page + 1})">[►] DALŠÍ (20)</button>` : ''}
<div style="margin-top:16px; display:flex; justify-content:space-between">
<div style="display:flex; gap:10px">
<button class="dos-btn" onclick="renderMainMenu()">[ESC] MENU</button>
${page > 0 ? `<button class="dos-btn" onclick="openKronika(${page - 1})">[◄] PŘEDCHOZÍ</button>` : ''}
${page < totalPages - 1 ? `<button class="dos-btn" onclick="openKronika(${page + 1})">[►] DALŠÍ</button>` : ''}
</div>
<div style="color:var(--dos-yellow); font-size:13px">
Záznam ${page * 20 + 1}–${Math.min((page + 1) * 20, HISTORICKA_JIDLA.length)} z ${HISTORICKA_JIDLA.length}
</div>
</div>
`;
screenEl.innerHTML = html;
}
// 10. ALTÁN / NÁPOVĚDA
function openAltan() {
currentView = 'altan';
playSpeaker(750, 0.05);
@@ -529,9 +925,12 @@ function openAltan() {
screenEl.innerHTML = html;
}
// Global Keyboard Handler (Turbo Pascal Hotkeys)
// =========================================================================
// GLOBAL KEYBOARD & TOUCH NAVIGATION
// =========================================================================
window.addEventListener('keydown', (e) => {
// If in input, only hijack ESC
// If typing in input, only ESC blurs
if (document.activeElement && document.activeElement.tagName === 'INPUT') {
if (e.key === 'Escape') {
document.activeElement.blur();
@@ -543,6 +942,11 @@ window.addEventListener('keydown', (e) => {
playSpeaker(800, 0.03);
switch (e.key) {
case '/':
case 'h': case 'H':
e.preventDefault();
openSearch();
break;
case 'r': case 'R': case 'F2':
e.preventDefault();
openReceptyList('Vše');
@@ -572,7 +976,7 @@ window.addEventListener('keydown', (e) => {
case 'Escape':
renderMainMenu();
break;
case '1': case '2': case '3': case '4': case '5': case '6': case '7':
case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8':
if (currentView === 'menu') {
const idx = parseInt(e.key);
if (KATEGORIE[idx]) openReceptyList(KATEGORIE[idx]);
@@ -586,6 +990,7 @@ document.querySelectorAll('[data-act]').forEach(el => {
el.addEventListener('click', () => {
const act = el.getAttribute('data-act');
if (act === 'menu') renderMainMenu();
else if (act === 'hledat') openSearch();
else if (act === 'recepty') openReceptyList('Vše');
else if (act === 'suroviny') openSuroviny();
else if (act === 'davno') openDavno();
+5 -3
View File
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -11,6 +11,7 @@
<!-- Turbo Pascal Top Menu Bar -->
<header id="menu-bar">
<span class="menu-item active" data-act="menu"><span class="hotkey">S</span>oubor</span>
<span class="menu-item" data-act="hledat"><span class="hotkey">H</span>ledat [/]</span>
<span class="menu-item" data-act="recepty"><span class="hotkey">R</span>eceptář (331)</span>
<span class="menu-item" data-act="suroviny"><span class="hotkey">Š</span>pajz</span>
<span class="menu-item" data-act="davno"><span class="hotkey">D</span>ávno</span>
@@ -37,6 +38,7 @@
<!-- Mobile Touch Keyboard Bar -->
<nav id="touch-controls">
<button class="t-btn highlight" data-act="hledat">HLEDAT [/]</button>
<button class="t-btn highlight" data-act="recepty">RECEPTÁŘ (331)</button>
<button class="t-btn highlight" data-act="suroviny">ŠPAJZ [S]</button>
<button class="t-btn" data-act="davno">DÁVNO [D]</button>
@@ -49,11 +51,12 @@
<!-- Turbo Pascal Bottom Status Bar -->
<footer id="status-bar">
<span class="f-key" data-act="altan"><span class="f-num">F1</span> Nápověda</span>
<span class="f-key" data-act="hledat"><span class="f-num">/</span> Hledat</span>
<span class="f-key" data-act="recepty"><span class="f-num">F2</span> Receptář</span>
<span class="f-key" data-act="suroviny"><span class="f-num">F3</span> Vařit ze špajzu</span>
<span class="f-key" data-act="davno"><span class="f-num">F4</span> Dávno nevařeno</span>
<span class="f-key" data-act="kronika"><span class="f-num">F5</span> Kronika 568</span>
<span class="f-key" data-act="menu"><span class="f-num">ESC</span> Hlavní menu</span>
<span class="f-key" data-act="suroviny"><span class="f-num">F3</span> Špajz</span>
<span class="f-key" data-act="davno"><span class="f-num">F4</span> Dávno</span>
<span class="f-key" data-act="kronika"><span class="f-num">F5</span> Kronika</span>
<span class="f-key" data-act="menu"><span class="f-num">ESC</span> Menu</span>
<span class="f-key sound-toggle" id="sound-btn">🔊 ZVUK: ON</span>
</footer>
</div>
+1747 -2552
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -238,6 +238,24 @@ html, body {
margin: 10px 0;
}
/* Highlight & Search Results */
.match-highlight {
background-color: var(--dos-yellow);
color: var(--dos-black);
font-weight: bold;
padding: 1px 3px;
border-radius: 2px;
}
.search-result-snippet {
font-size: 13px;
color: var(--dos-grey);
margin-top: 3px;
padding-left: 10px;
line-height: 1.4;
border-left: 2px solid var(--dos-cyan);
}
/* Touch controls on mobile */
#touch-controls {
display: none;