Files
SMF/app/js/search.js

203 lines
6.0 KiB
JavaScript
Raw Normal View History

// =====================================================
// 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;