Komplett pensumstudie-app med: - 120 flashcards, 49 quiz-spørsmål, 16 eksamenstrener-oppgaver - Sammendrag av alle 12 forelesninger - tl;dr-side for siste-minutts pugging - Søk gjennom hele pensumet - Dark/light mode, mobile-vennlig Cross-platform launchere (Start.sh + Start.bat) med auto-detect og auto-install av HTTP-server. PowerShell-fallback for Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
203 lines
6.0 KiB
JavaScript
203 lines
6.0 KiB
JavaScript
// =====================================================
|
|
// Search — index across all notes and concepts
|
|
// =====================================================
|
|
|
|
let searchIndex = null;
|
|
|
|
async function buildSearchIndex() {
|
|
if (searchIndex) return searchIndex;
|
|
const idx = [];
|
|
|
|
// Index concepts from THEMES
|
|
Object.values(SMF.THEMES).forEach(theme => {
|
|
theme.keyConcepts.forEach(c => {
|
|
idx.push({
|
|
type: 'concept',
|
|
category: theme.label,
|
|
title: c.term,
|
|
snippet: c.def,
|
|
href: `#/tema/${theme.id}`,
|
|
searchText: `${c.term} ${c.def}`.toLowerCase()
|
|
});
|
|
});
|
|
});
|
|
|
|
// Index weeks
|
|
SMF.WEEKS.forEach(w => {
|
|
idx.push({
|
|
type: 'uke',
|
|
category: SMF.themeLabel(w.theme),
|
|
title: `Uke ${w.id}: ${w.title}`,
|
|
snippet: '',
|
|
href: `#/uke/${w.id}`,
|
|
searchText: `uke ${w.id} ${w.title}`.toLowerCase()
|
|
});
|
|
});
|
|
|
|
// Load all notes and extract headings + paragraphs
|
|
const noteFiles = ['uke02-03-04.md', 'uke05-06.md', 'uke07-08.md', 'uke10-11.md', 'uke12-16.md', 'uke17-eksamen.md'];
|
|
for (const file of noteFiles) {
|
|
try {
|
|
const md = await SMF.loadNote(file);
|
|
const lines = md.split('\n');
|
|
let currentWeek = null;
|
|
let currentH = null;
|
|
let buffer = [];
|
|
const flush = () => {
|
|
if (buffer.length && currentH) {
|
|
const txt = buffer.join(' ').trim();
|
|
if (txt.length > 20) {
|
|
idx.push({
|
|
type: 'avsnitt',
|
|
category: currentWeek ? `Uke ${currentWeek}` : 'Pensum',
|
|
title: currentH,
|
|
snippet: txt.slice(0, 220) + (txt.length > 220 ? '…' : ''),
|
|
href: currentWeek ? `#/uke/${currentWeek}` : `#/uke/17`,
|
|
searchText: (currentH + ' ' + txt).toLowerCase()
|
|
});
|
|
}
|
|
}
|
|
buffer = [];
|
|
};
|
|
for (const line of lines) {
|
|
const ukeMatch = line.match(/^##\s+Uke\s+(\d+)/i);
|
|
if (ukeMatch) {
|
|
flush();
|
|
currentWeek = parseInt(ukeMatch[1], 10);
|
|
currentH = line.replace(/^#+\s+/, '');
|
|
continue;
|
|
}
|
|
const hMatch = line.match(/^#{3,4}\s+(.+)/);
|
|
if (hMatch) {
|
|
flush();
|
|
currentH = hMatch[1].trim();
|
|
continue;
|
|
}
|
|
if (line.trim() && !line.startsWith('|') && !line.startsWith('---')) {
|
|
buffer.push(line.replace(/[*_`]/g, ''));
|
|
}
|
|
}
|
|
flush();
|
|
} catch (e) {
|
|
console.warn('Could not load note for indexing:', file, e);
|
|
}
|
|
}
|
|
|
|
searchIndex = idx;
|
|
return idx;
|
|
}
|
|
|
|
function searchQuery(q) {
|
|
if (!searchIndex || !q || q.length < 2) return [];
|
|
const qLow = q.toLowerCase();
|
|
const terms = qLow.split(/\s+/).filter(t => t.length > 1);
|
|
if (!terms.length) return [];
|
|
|
|
const scored = [];
|
|
for (const item of searchIndex) {
|
|
let score = 0;
|
|
let titleHits = 0;
|
|
const titleLow = item.title.toLowerCase();
|
|
terms.forEach(t => {
|
|
if (titleLow.includes(t)) { score += 10; titleHits++; }
|
|
if (item.searchText.includes(t)) { score += 2; }
|
|
});
|
|
// Bonus: type-priority — concepts > weeks > paragraphs
|
|
if (item.type === 'concept') score += 3;
|
|
else if (item.type === 'uke') score += 2;
|
|
// Bonus: title contains whole query
|
|
if (titleLow.includes(qLow)) score += 8;
|
|
if (score > 0) scored.push({ item, score, titleHits });
|
|
}
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored.slice(0, 12).map(s => s.item);
|
|
}
|
|
|
|
function highlight(text, q) {
|
|
if (!q) return text;
|
|
const terms = q.toLowerCase().split(/\s+/).filter(t => t.length > 1);
|
|
let out = text;
|
|
terms.forEach(t => {
|
|
const re = new RegExp(`(${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
out = out.replace(re, '<mark>$1</mark>');
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function attachSearch() {
|
|
const input = document.getElementById('searchInput');
|
|
const results = document.getElementById('searchResults');
|
|
if (!input || !results) return;
|
|
|
|
let timer = null;
|
|
|
|
input.addEventListener('input', async (e) => {
|
|
const q = e.target.value.trim();
|
|
if (!q || q.length < 2) {
|
|
results.classList.remove('search-results--open');
|
|
results.innerHTML = '';
|
|
return;
|
|
}
|
|
await buildSearchIndex();
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
const matches = searchQuery(q);
|
|
if (!matches.length) {
|
|
results.innerHTML = `<div class="search-result"><div class="search-result__title">Ingen treff på "${q}"</div></div>`;
|
|
} else {
|
|
results.innerHTML = matches.map(m => `
|
|
<a class="search-result" href="${m.href}">
|
|
<div class="search-result__type">${m.type} · ${m.category}</div>
|
|
<div class="search-result__title">${highlight(m.title, q)}</div>
|
|
${m.snippet ? `<div class="search-result__snippet">${highlight(m.snippet, q)}</div>` : ''}
|
|
</a>
|
|
`).join('');
|
|
}
|
|
results.classList.add('search-results--open');
|
|
}, 80);
|
|
});
|
|
|
|
input.addEventListener('focus', () => {
|
|
if (input.value.length >= 2) {
|
|
results.classList.add('search-results--open');
|
|
}
|
|
});
|
|
|
|
// Hide on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!input.contains(e.target) && !results.contains(e.target)) {
|
|
results.classList.remove('search-results--open');
|
|
}
|
|
});
|
|
|
|
// Click result -> close
|
|
results.addEventListener('click', (e) => {
|
|
const a = e.target.closest('.search-result');
|
|
if (a) {
|
|
results.classList.remove('search-results--open');
|
|
input.value = '';
|
|
input.blur();
|
|
}
|
|
});
|
|
|
|
// Keyboard: '/' to focus
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === '/' && document.activeElement !== input && !e.metaKey && !e.ctrlKey) {
|
|
const tag = document.activeElement?.tagName;
|
|
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
|
|
e.preventDefault();
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
results.classList.remove('search-results--open');
|
|
input.blur();
|
|
}
|
|
});
|
|
}
|
|
|
|
SMF.attachSearch = attachSearch;
|
|
SMF.buildSearchIndex = buildSearchIndex;
|