// ── Search ── import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js'; import { state, parseTags, tagChipHtml } 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; const ptags = parseTags(card); const ptagEl = $('search-preview-tags'); if (ptags.length) { ptagEl.classList.remove('hidden'); ptagEl.innerHTML = ptags.map(t => tagChipHtml(t)).join(''); ptagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => { seg.addEventListener('click', e => { closeSearch(); const tag = seg.dataset.tag; if (e.ctrlKey || e.metaKey) { const idx = state.activeTags.indexOf(tag); if (idx >= 0) state.activeTags.splice(idx, 1); else state.activeTags.push(tag); } else { state.activeTags = [tag]; } window._renderTagBar?.(); window._renderBoard(); }); }); } else { ptagEl.classList.add('hidden'); } $('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; } // Split into #tag filters and regular words const parts = q.split(/\s+/).filter(Boolean); const tagFilters = parts.filter(p => p.startsWith('#') && p.length > 1).map(p => p.substring(1)); const words = parts.filter(p => !p.startsWith('#')); const textQ = words.join(' '); // Pre-filter by tags (must match ALL) let pool = getPool(); if (tagFilters.length) { pool = pool.filter(c => { const ct = (c.tags || '').toLowerCase().split(','); return tagFilters.every(tf => ct.some(t => t === tf || t.startsWith(tf + '/') || t.includes(tf))); }); } // Tag-only search (no words) if (!words.length) { hits = pool; active = pool.length ? 0 : -1; if (!pool.length) { results.innerHTML = `

no cards with ${tagFilters.map(t => '#' + t).join(' + ')}

`; footer.classList.add('hidden'); clearPreview(); return; } results.innerHTML = `
${pool.length} tagged
` + pool.map((c, i) => hitHtml(c, i, null)).join(''); footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return; } const scored = pool.map(c => { const title = (c.title || '').toLowerCase(), body = stripMd(c.body || '').toLowerCase(); const tags = (c.tags || '').toLowerCase(); let score = tagFilters.length ? 50 : 0, matched = tagFilters.length > 0; if (title.includes(textQ)) { score += 150; matched = true; } if (body.includes(textQ)) { score += 40 + countSubstring(body, textQ) * 10; matched = true; } if (tags.includes(textQ)) { score += 60; 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 (tags.includes(w)) score += 25; if (!inT && !inB) allWords = false; } if (allWords) { score += 50; matched = true; } if (!matched) { const tf = fuzzyMatch(textQ, title); if (tf !== null) { score += 15 + tf * 2; 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 ? '...' : ''); } const htags = parseTags(card); return `
${title}
${snippet}
${htags.length ? `
${htags.slice(0, 3).map(t => `#${esc(t)}`).join('')}
` : ''}
`; } 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);