feature: index search

This commit is contained in:
Matthias Jung
2026-04-06 22:03:19 +02:00
parent bdedf0d16a
commit 69302bbf6d
+242 -187
View File
@@ -1,225 +1,280 @@
<h1>Fragensuche</h1>
<h1>Suche</h1>
<style>
#question-search-results .breadcrumb,
#question-search-results .breadcrumb li,
#question-search-results .breadcrumb-item {
margin-bottom: 0;
}
#question-search-results .breadcrumb,
#question-search-results .breadcrumb li,
#question-search-results .breadcrumb-item {
margin-bottom: 0;
}
</style>
<div class="card mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-md-6 col-lg-4">
<label for="question-search-input" class="form-label">Suche nach einer Fragennummer:</label>
<input
id="question-search-input"
class="form-control"
type="text"
placeholder="VD102"
autocomplete="off"
disabled
>
</div>
</div>
<div id="question-search-error" class="alert alert-danger mt-3 mb-0 d-none" role="alert"></div>
</div>
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-md-6 col-lg-4">
<label for="question-search-input" class="form-label">Suche nach Frage-ID oder Begriff:</label>
<input
id="question-search-input"
class="form-control"
type="text"
placeholder="VD102, PTT, ..."
autocomplete="off"
disabled
>
</div>
</div>
<div id="question-search-error" class="alert alert-danger mt-3 mb-0 d-none" role="alert"></div>
</div>
</div>
<div id="question-search-results" class="d-none"></div>
<script>
(() => {
const searchInput = document.getElementById("question-search-input");
const errorBox = document.getElementById("question-search-error");
const resultsBox = document.getElementById("question-search-results");
let questionIndex = {};
(() => {
const searchInput = document.getElementById("question-search-input");
const errorBox = document.getElementById("question-search-error");
const resultsBox = document.getElementById("question-search-results");
const targetClassByEdition = {
N: "N",
E: "E",
NE: "E",
A: "A",
EA: "A",
NEA: "A",
};
let questionIndex = {};
let indexEntries = [];
const targetColorByClass = {
N: "#47ABE8",
E: "#FE756C",
A: "#3BB583",
};
const targetClassByEdition = { N: "N", E: "E", NE: "E", A: "A", EA: "A", NEA: "A" };
const targetColorByClass = { N: "#47ABE8", E: "#FE756C", A: "#3BB583" };
const editionOrder = ["N", "NE", "NEA", "E", "EA", "A"];
const editionLabelByEdition = {
N: "N",
NE: "N+E",
NEA: "N+E+A",
E: "N -> E",
EA: "N -> A",
A: "E -> A",
};
const editionOrder = ["N", "NE", "NEA", "E", "EA", "A"];
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
const editionLabelByEdition = {
N: "N",
NE: "N+E",
NEA: "N+E+A",
E: "N -> E",
EA: "N -> A",
A: "E -> A",
};
function normalizeQuestionQuery(value) {
return (value || "").trim().toUpperCase();
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeText(value) {
return (value || "").trim().toLocaleLowerCase("de-DE");
}
function normalizeQuery(value) {
return (value || "").trim().toUpperCase();
}
function getTargetClass(edition) {
return targetClassByEdition[(edition || "").toUpperCase()] || null;
}
function getTargetClass(edition) {
return targetClassByEdition[(edition || "").toUpperCase()] || null;
}
function getEditionLabel(edition) {
return editionLabelByEdition[(edition || "").toUpperCase()] || edition;
}
function getEditionLabel(edition) {
return editionLabelByEdition[(edition || "").toUpperCase()] || edition;
}
function sortEditions(editions) {
return [...editions].sort((a, b) => {
const aUpper = (a || "").toUpperCase();
const bUpper = (b || "").toUpperCase();
const indexA = editionOrder.indexOf(aUpper);
const indexB = editionOrder.indexOf(bUpper);
function sortEditions(editions) {
return [...editions].sort((a, b) => {
const aUpper = (a || "").toUpperCase();
const bUpper = (b || "").toUpperCase();
const indexA = editionOrder.indexOf(aUpper);
const indexB = editionOrder.indexOf(bUpper);
if (indexA === -1 && indexB === -1) return aUpper.localeCompare(bUpper);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
}
if (indexA === -1 && indexB === -1) {
return aUpper.localeCompare(bUpper);
}
if (indexA === -1) {
return 1;
}
if (indexB === -1) {
return -1;
}
function renderEditionButtons(editions, sectionIdentRaw, fragmentId) {
const sortedEditions = sortEditions(Array.isArray(editions) ? editions : []);
return sortedEditions
.map((edition) => {
const editionSafe = escapeHtml(edition);
const editionLabel = escapeHtml(getEditionLabel(edition));
const targetClass = getTargetClass(edition);
const targetColor = targetColorByClass[targetClass] || "#6c757d";
const targetTitle = targetClass ? `Zielklasse ${targetClass}` : "Zielklasse unbekannt";
const href = `${encodeURIComponent(edition)}_${encodeURIComponent(sectionIdentRaw)}.html#${encodeURIComponent(fragmentId)}`;
return `<a class="btn btn-sm text-white text-decoration-none d-inline-flex align-items-center gap-2 px-3 py-2 text-nowrap" style="background-color: ${targetColor}; border-color: ${targetColor};" href="${href}" title="${escapeHtml(targetTitle)}" aria-label="${editionSafe}: ${editionLabel}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor" aria-hidden="true" focusable="false" style="width: 0.95rem; height: 0.95rem;"><path d="M96 0C43 0 0 43 0 96L0 416c0 53 43 96 96 96l288 0 32 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l0-64c17.7 0 32-14.3 32-32l0-320c0-17.7-14.3-32-32-32L384 0 96 0zm0 384l256 0 0 64L96 448c-17.7 0-32-14.3-32-32s14.3-32 32-32zm32-240c0-8.8 7.2-16 16-16l192 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-192 0c-8.8 0-16-7.2-16-16zm16 48l192 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-192 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z"></path></svg>${editionLabel}</a>`;
})
.join("");
}
return indexA - indexB;
});
}
function renderQuestionCards(matches) {
return matches.map((questionId) => {
const entry = questionIndex[questionId] || {};
const sectionIdentRaw = entry.section || "";
const chapterTitle = escapeHtml(entry.chapter_title || "Unbekanntes Kapitel");
const sectionTitle = escapeHtml(entry.section_title || entry.section || "Unbekannte Sektion");
const hasSolution = Boolean(entry.has_solution);
const editionLinks = renderEditionButtons(entry.editions, sectionIdentRaw, questionId);
const solutionLink = hasSolution
? `<a class="btn btn-sm text-dark d-inline-flex align-items-center gap-2 px-3 py-2 text-nowrap" style="background-color: #ffc107; border-color: #ffc107;" href="${encodeURIComponent(questionId)}.html" aria-label="Loesungsweg zu ${escapeHtml(questionId)}"><svg class="solution-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill="currentColor" aria-hidden="true" focusable="false" style="width: 1rem; height: 1rem;"><path d="M420.9 448C428.2 425.7 442.8 405.5 459.3 388.1C492 353.7 512 307.2 512 256C512 150 426 64 320 64C214 64 128 150 128 256C128 307.2 148 353.7 180.7 388.1C197.2 405.5 211.9 425.7 219.1 448L420.8 448zM416 496L224 496L224 512C224 556.2 259.8 592 304 592L336 592C380.2 592 416 556.2 416 512L416 496zM312 176C272.2 176 240 208.2 240 248C240 261.3 229.3 272 216 272C202.7 272 192 261.3 192 248C192 181.7 245.7 128 312 128C325.3 128 336 138.7 336 152C336 165.3 325.3 176 312 176z"></path></svg>Loesungsweg</a>`
: "";
function renderSearchResults() {
const query = normalizeQuery(searchInput.value);
const questionIds = Object.keys(questionIndex);
return `
<div class="card mb-3">
<div class="card-header d-flex flex-nowrap align-items-center gap-2 overflow-auto">
<div class="fw-semibold">${escapeHtml(questionId)}</div>
<nav aria-label="breadcrumb" class="mb-0 d-flex align-items-center text-nowrap">
<ol class="breadcrumb mb-0 align-items-center flex-nowrap">
<li class="breadcrumb-item">${chapterTitle}</li>
<li class="breadcrumb-item">${sectionTitle}</li>
</ol>
</nav>
</div>
<div class="card-body py-3">
<div class="d-flex flex-wrap align-items-center gap-2">
${editionLinks || '<span class="badge text-bg-secondary">-</span>'}
${solutionLink}
</div>
</div>
</div>
`;
});
}
resultsBox.classList.remove("d-none");
function renderIndexCards(matches) {
return matches.map((entry) => {
const term = escapeHtml(entry.term || "");
const subterm = entry.subterm ? escapeHtml(entry.subterm) : "";
const anchorId = entry.anchor_id || "";
const sectionIdentRaw = entry.section || "";
const chapterTitle = escapeHtml(entry.chapter_title || "Unbekanntes Kapitel");
const sectionTitle = escapeHtml(entry.section_title || entry.section || "Unbekannte Sektion");
const editionLinks = renderEditionButtons(entry.editions, sectionIdentRaw, anchorId);
if (query.length === 0) {
resultsBox.innerHTML =
'<div class="alert alert-light border" role="status">Bitte gib eine Frage-ID ein, z. B. <strong>VD102</strong>.</div>';
return;
}
return `
<div class="card mb-3">
<div class="card-header d-flex flex-nowrap align-items-center gap-2 overflow-auto">
<div class="fw-semibold">Index: ${term}${subterm ? ` / ${subterm}` : ""}</div>
<nav aria-label="breadcrumb" class="mb-0 d-flex align-items-center text-nowrap">
<ol class="breadcrumb mb-0 align-items-center flex-nowrap">
<li class="breadcrumb-item">${chapterTitle}</li>
<li class="breadcrumb-item">${sectionTitle}</li>
</ol>
</nav>
</div>
<div class="card-body py-3">
<div class="d-flex flex-wrap align-items-center gap-2">
${editionLinks || '<span class="badge text-bg-secondary">-</span>'}
</div>
</div>
</div>
`;
});
}
const exactMatches = questionIds.filter((questionId) => questionId === query);
const startsWithMatches = questionIds.filter((questionId) => questionId.startsWith(query) && questionId !== query);
const includesMatches = questionIds.filter(
(questionId) => questionId.includes(query) && !questionId.startsWith(query)
);
function findQuestionMatches(questionQuery) {
const questionIds = Object.keys(questionIndex);
const exactMatches = questionIds.filter((questionId) => questionId === questionQuery);
const startsWithMatches = questionIds.filter((questionId) => questionId.startsWith(questionQuery) && questionId !== questionQuery);
const includesMatches = questionIds.filter((questionId) => questionId.includes(questionQuery) && !questionId.startsWith(questionQuery));
return [...exactMatches, ...startsWithMatches, ...includesMatches];
}
const matches = [...exactMatches, ...startsWithMatches, ...includesMatches];
function findIndexMatches(textQuery) {
const exactMatches = [];
const startsWithMatches = [];
const includesMatches = [];
if (matches.length === 0) {
resultsBox.innerHTML =
'<div class="alert alert-warning" role="status">Keine passende Frage gefunden.</div>';
return;
}
for (const entry of indexEntries) {
const term = normalizeText(entry.term);
const subterm = normalizeText(entry.subterm);
const combined = subterm ? `${term} ${subterm}` : term;
const cards = matches.map((questionId) => {
const entry = questionIndex[questionId] || {};
const sectionIdentRaw = entry.section || "";
const chapterTitle = escapeHtml(entry.chapter_title || "Unbekanntes Kapitel");
const sectionTitle = escapeHtml(entry.section_title || entry.section || "Unbekannte Sektion");
const hasSolution = Boolean(entry.has_solution);
const editions = sortEditions(Array.isArray(entry.editions) ? entry.editions : []);
const editionLinks = editions
.map((edition) => {
const editionSafe = escapeHtml(edition);
const editionLabel = escapeHtml(getEditionLabel(edition));
const targetClass = getTargetClass(edition);
const targetColor = targetColorByClass[targetClass] || "#6c757d";
const targetTitle = targetClass ? `Zielklasse ${targetClass}` : "Zielklasse unbekannt";
const href = `${encodeURIComponent(edition)}_${encodeURIComponent(sectionIdentRaw)}.html#${encodeURIComponent(questionId)}`;
return `<a class="btn btn-sm text-white text-decoration-none d-inline-flex align-items-center gap-2 px-3 py-2 text-nowrap" style="background-color: ${targetColor}; border-color: ${targetColor};" href="${href}" title="${escapeHtml(targetTitle)}" aria-label="${editionSafe}: ${editionLabel}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor" aria-hidden="true" focusable="false" style="width: 0.95rem; height: 0.95rem;"><path d="M96 0C43 0 0 43 0 96L0 416c0 53 43 96 96 96l288 0 32 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l0-64c17.7 0 32-14.3 32-32l0-320c0-17.7-14.3-32-32-32L384 0 96 0zm0 384l256 0 0 64L96 448c-17.7 0-32-14.3-32-32s14.3-32 32-32zm32-240c0-8.8 7.2-16 16-16l192 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-192 0c-8.8 0-16-7.2-16-16zm16 48l192 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-192 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z"></path></svg>${editionLabel}</a>`;
})
.join("");
const solutionLink = hasSolution
? `<a class="btn btn-sm text-dark d-inline-flex align-items-center gap-2 px-3 py-2 text-nowrap" style="background-color: #ffc107; border-color: #ffc107;" href="${encodeURIComponent(questionId)}.html" aria-label="Lösungsweg zu ${escapeHtml(questionId)}">
<svg class="solution-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" fill="currentColor" aria-hidden="true" focusable="false" style="width: 1rem; height: 1rem;">
<path d="M420.9 448C428.2 425.7 442.8 405.5 459.3 388.1C492 353.7 512 307.2 512 256C512 150 426 64 320 64C214 64 128 150 128 256C128 307.2 148 353.7 180.7 388.1C197.2 405.5 211.9 425.7 219.1 448L420.8 448zM416 496L224 496L224 512C224 556.2 259.8 592 304 592L336 592C380.2 592 416 556.2 416 512L416 496zM312 176C272.2 176 240 208.2 240 248C240 261.3 229.3 272 216 272C202.7 272 192 261.3 192 248C192 181.7 245.7 128 312 128C325.3 128 336 138.7 336 152C336 165.3 325.3 176 312 176z"></path>
</svg>
Lösungsweg
</a>`
: "";
if (term === textQuery || subterm === textQuery || combined === textQuery) {
exactMatches.push(entry);
continue;
}
return `
<div class="card mb-3">
<div class="card-header d-flex flex-nowrap align-items-center gap-2 overflow-auto">
<div class="fw-semibold">${escapeHtml(questionId)}</div>
<nav aria-label="breadcrumb" class="mb-0 d-flex align-items-center text-nowrap">
<ol class="breadcrumb mb-0 align-items-center flex-nowrap">
<li class="breadcrumb-item">${chapterTitle}</li>
<li class="breadcrumb-item">${sectionTitle}</li>
</ol>
</nav>
</div>
<div class="card-body py-3">
<div class="d-flex flex-wrap align-items-center gap-2">
${editionLinks || '<span class="badge text-bg-secondary">-</span>'}
${solutionLink}
</div>
</div>
</div>
`;
});
if (term.startsWith(textQuery) || subterm.startsWith(textQuery) || combined.startsWith(textQuery)) {
startsWithMatches.push(entry);
continue;
}
resultsBox.innerHTML = `
<p class="text-muted mb-3">${cards.length} Treffer</p>
${cards.join("")}
`;
}
if (term.includes(textQuery) || subterm.includes(textQuery) || combined.includes(textQuery)) {
includesMatches.push(entry);
}
}
async function loadQuestionIndex() {
try {
const response = await fetch("/assets/question_index.json", {
cache: "no-cache",
headers: {
Accept: "application/json",
},
});
return [...exactMatches, ...startsWithMatches, ...includesMatches];
}
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
function renderSearchResults() {
const rawQuery = (searchInput.value || "").trim();
const questionQuery = normalizeQuestionQuery(rawQuery);
const textQuery = normalizeText(rawQuery);
const indexData = await response.json();
resultsBox.classList.remove("d-none");
questionIndex = indexData || {};
if (rawQuery.length === 0) {
resultsBox.innerHTML = '<div class="alert alert-light border" role="status">Bitte gib eine Frage-ID oder einen Begriff ein, z. B. <strong>VD102</strong> oder <strong>PTT</strong>.</div>';
return;
}
searchInput.disabled = false;
const questionMatches = findQuestionMatches(questionQuery);
const indexMatches = findIndexMatches(textQuery);
searchInput.addEventListener("input", renderSearchResults);
searchInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
renderSearchResults();
}
});
if (questionMatches.length === 0 && indexMatches.length === 0) {
resultsBox.innerHTML = '<div class="alert alert-warning" role="status">Keine passenden Fragen oder Begriffe gefunden.</div>';
return;
}
renderSearchResults();
} catch (error) {
errorBox.classList.remove("d-none");
errorBox.textContent =
"Suchindex konnte nicht geladen werden: " + error.message;
console.error("Failed to load question_index.json", error);
}
}
const questionCards = renderQuestionCards(questionMatches);
const indexCards = renderIndexCards(indexMatches);
const total = questionCards.length + indexCards.length;
loadQuestionIndex();
})();
</script>
resultsBox.innerHTML = `
<p class="text-muted mb-3">${total} Treffer (${questionCards.length} Fragen, ${indexCards.length} Begriffe)</p>
${questionCards.join("")}
${indexCards.join("")}
`;
}
async function loadSearchIndices() {
try {
const [questionResponse, indexResponse] = await Promise.all([
fetch("/assets/question_index.json", {
cache: "no-cache",
headers: { Accept: "application/json" },
}),
fetch("/assets/index.json", {
cache: "no-cache",
headers: { Accept: "application/json" },
}),
]);
if (!questionResponse.ok) throw new Error("question_index.json: HTTP " + questionResponse.status);
if (!indexResponse.ok) throw new Error("index.json: HTTP " + indexResponse.status);
const questionData = await questionResponse.json();
const indexData = await indexResponse.json();
questionIndex = questionData || {};
indexEntries = Array.isArray(indexData) ? indexData : [];
searchInput.disabled = false;
searchInput.addEventListener("input", renderSearchResults);
searchInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
renderSearchResults();
}
});
renderSearchResults();
} catch (error) {
errorBox.classList.remove("d-none");
errorBox.textContent = "Suchindex konnte nicht geladen werden: " + error.message;
console.error("Failed to load search indices", error);
}
}
loadSearchIndices();
})();
</script>