// ── Search ── import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js'; import { state } from './state.js'; import { renderMd, stripMd } from './markdown.js'; const overlay = $('search'); const input = $('search-input'); const results = $('search-results'); const footer = $('search-footer'); const previewEmpty = $('search-preview-empty'); const previewContent = $('search-preview-content'); const bucketFilter = $('search-bucket-filter'); let active = -1, hits = []; export function openSearch() { window._closeTasks?.(); input.value = ''; results.innerHTML = ''; footer.classList.add('hidden'); active = -1; overlay.classList.add('open'); input.focus(); clearPreview(); renderHome(); } export function closeSearch() { overlay.classList.remove('open'); } function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); } function getPool() { const bv = bucketFilter.value; if (!bv) return state.cards; if (bv === 'none') return state.cards.filter(c => !c.bucket_id); return state.cards.filter(c => c.bucket_id == bv); } function updatePreview() { const card = hits[active]; if (!card) { clearPreview(); return; } previewEmpty.classList.add('hidden'); previewContent.classList.remove('hidden'); $('search-preview-color').style.background = card.color; const q = input.value.trim(); const words = q ? q.toLowerCase().split(/\s+/).filter(Boolean) : []; $('search-preview-title').innerHTML = words.length ? highlight(esc(card.title), words) : esc(card.title); let html = renderMd(card.body); if (words.length) { for (const w of words) html = html.replace(new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'), '$1$2$3'); } $('search-preview-body').innerHTML = html; $('search-preview-meta').textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); } function renderHome() { const recent = [...getPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6); if (!recent.length) { results.innerHTML = `
no cards yet
`; return; } results.innerHTML = `
recent
` + recent.map((c, i) => hitHtml(c, i, null)).join(''); hits = recent; active = recent.length ? 0 : -1; footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); } function search(query) { const q = query.toLowerCase().trim(); if (!q) { renderHome(); return; } const words = q.split(/\s+/).filter(Boolean); const scored = getPool().map(c => { const title = (c.title || '').toLowerCase(), body = stripMd(c.body || '').toLowerCase(); let score = 0, matched = false; if (title.includes(q)) { score += 150; matched = true; } if (body.includes(q)) { score += 40 + countSubstring(body, q) * 10; matched = true; } let allWords = true; for (const w of words) { const inT = title.includes(w), inB = body.includes(w); if (inT) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; } if (inB) score += 8 + countSubstring(body, w) * 3; if (!inT && !inB) allWords = false; } if (allWords) { score += 50; matched = true; } const tf = fuzzyMatch(q, title), bf = fuzzyMatch(q, body); if (tf !== null) { score += 20 + tf * 2; matched = true; } if (bf !== null) { score += 5 + bf * 0.5; matched = true; } if (!matched) { let fh = 0; for (const w of words) { if (fuzzyMatch(w, title) !== null) { score += 10; fh++; } if (fuzzyMatch(w, body) !== null) { score += 3; fh++; } } if (fh > 0) matched = true; } if (!matched) return null; return { card: c, score: score - title.length * 0.1 }; }).filter(Boolean).sort((a, b) => b.score - a.score); hits = scored.map(s => s.card); active = scored.length ? 0 : -1; if (!scored.length) { results.innerHTML = `

no results for "${esc(query)}"

`; footer.classList.add('hidden'); clearPreview(); return; } results.innerHTML = `
${scored.length} result${scored.length > 1 ? 's' : ''}
` + scored.map((s, i) => hitHtml(s.card, i, words)).join(''); footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); } function hitHtml(card, idx, words) { const title = words ? highlight(esc(card.title), words) : esc(card.title); const plain = stripMd(card.body || ''); let snippet = ''; if (words && plain) { const lower = plain.toLowerCase(), fullQ = words.join(' '), winSize = 160; let best = 0, bestScore = -1; for (let i = 0; i < lower.length; i += 10) { const win = lower.substring(i, i + winSize); let ws = win.includes(fullQ) ? 50 : 0; let fh = -1, lh = -1; for (const w of words) { let p = 0, h = 0; while ((p = win.indexOf(w, p)) !== -1) { h++; if (fh === -1 || p < fh) fh = p; if (p > lh) lh = p + w.length; p += w.length; } ws += h * 8; } if (fh !== -1 && lh !== -1) ws += Math.max(0, 30 - (lh - fh) / 2); if (ws > bestScore) { bestScore = ws; best = i; } } const center = Math.min(best + winSize / 2, lower.length); const start = Math.max(0, Math.floor(center - winSize / 2)); const end = Math.min(plain.length, start + winSize); snippet = (start > 0 ? '...' : '') + plain.substring(start, end).trim() + (end < plain.length ? '...' : ''); snippet = highlight(esc(snippet), words); } else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); } return `
${title}
${snippet}
`; } function bindHits() { results.querySelectorAll('.search-hit').forEach(el => { el.addEventListener('click', () => { const i = parseInt(el.dataset.idx); if (active === i && hits[i]) window._openDetail(hits[i].id); else { active = i; updateActive(); } }); el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); }); }); } function updateActive() { results.querySelectorAll('.search-hit').forEach((el, i) => { el.classList.toggle('active', i === active); if (i === active) el.scrollIntoView({ block: 'nearest' }); }); updatePreview(); } input.addEventListener('input', () => search(input.value)); bucketFilter?.addEventListener('change', () => search(input.value)); input.addEventListener('keydown', e => { const n = results.querySelectorAll('.search-hit').length; if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, n - 1); updateActive(); } else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); updateActive(); } else if (e.key === 'Enter' && active >= 0 && hits[active]) { e.preventDefault(); window._openDetail(hits[active].id); } }); overlay.addEventListener('click', e => { if (e.target === overlay) closeSearch(); }); $('search-btn')?.addEventListener('click', openSearch); $('search-close')?.addEventListener('click', closeSearch);