// ── Search ── import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js'; import { state, parseTags, tagChipHtml, getChildren } from './state.js'; import { renderMd, stripMd } from './markdown.js'; import { showDialog } from './dialog.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'); const replaceToggle = $('search-replace-toggle'); const replaceRow = $('search-replace-row'); const replaceInput = $('search-replace-input'); const replaceStatus = $('search-replace-status'); const replaceAllBtn = $('search-replace-all'); let active = -1, hits = [], replaceMode = false, freshCards = null; export function openSearch({ replace = false } = {}) { window._closeTasks?.(); input.value = ''; results.innerHTML = ''; footer.classList.add('hidden'); active = -1; overlay.classList.add('open'); setReplaceMode(replace); input.focus(); clearPreview(); renderHome(); } async function setReplaceMode(on) { replaceMode = on; replaceRow?.classList.toggle('hidden', !on); replaceToggle?.classList.toggle('active', on); if (on) { replaceInput.value = ''; freshCards = await api('api/cards') || []; } else { freshCards = null; } updateReplaceState(); } function updateReplaceState() { if (!replaceMode || !replaceAllBtn) return; const q = input.value.trim(); const hasRepl = replaceInput?.value.length > 0; const hasMatch = hits.length > 0 && q.length > 0; replaceAllBtn.disabled = !(hasMatch && hasRepl); if (q && hits.length) { let total = 0; const nLower = q.toLowerCase(); for (const c of hits) { const body = (c.body || '').toLowerCase(); let pos = 0; while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; total++; pos = i + 1; } } replaceStatus.textContent = total ? `${total} in ${hits.length} card${hits.length !== 1 ? 's' : ''}` : ''; } else { replaceStatus.textContent = ''; } } export function closeSearch() { overlay.classList.remove('open'); if (document.activeElement && document.activeElement !== document.body) { document.activeElement.blur(); } } function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); } function getPool() { const cards = (replaceMode && freshCards) ? freshCards : state.cards; const bv = bucketFilter.value; if (!bv) return cards; if (bv === 'none') return cards.filter(c => !c.bucket_id); return 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 = ``; 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(); updateReplaceState(); } 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 = `
`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); return; } results.innerHTML = `
${pool.length} tagged
` + pool.map((c, i) => hitHtml(c, i, null)).join(''); footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); updateReplaceState(); 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 = `
`; footer.classList.add('hidden'); clearPreview(); updateReplaceState(); 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(); updateReplaceState(); } 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); const children = getChildren(card.id); const isGroup = children.length > 0; const canDrag = !isGroup && !card.parent_id; return `
${title}${isGroup ? `${children.length + 1}` : ''}
${snippet}
${htags.length ? `
${htags.slice(0, 3).map(t => `#${esc(t)}`).join('')}
` : ''}
`; } function openBucketPicker(cardId, x, y) { document.querySelector('.search-bucket-picker')?.remove(); const picker = document.createElement('div'); picker.className = 'search-bucket-picker'; // Clamp to viewport so a click near the bottom-right doesn't flow // off-screen — rendered invisibly first to measure, then placed. picker.style.cssText = `position:fixed;left:0;top:0;visibility:hidden;z-index:300;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:280px;display:flex;flex-direction:column;`; const list = document.createElement('div'); list.style.cssText = 'overflow-y:auto;flex:1;'; const addRow = (label, color, onClick) => { const item = document.createElement('button'); item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;'; item.innerHTML = `${esc(label)}`; item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; }); item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; }); item.addEventListener('click', onClick); list.appendChild(item); }; addRow('(unsorted)', 'transparent', async () => { picker.remove(); await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: null } }); await window._reloadAll(); search(input.value); }); state.buckets.forEach(b => { addRow(b.name, b.color || '#64748b', async () => { picker.remove(); await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: b.id } }); await window._reloadAll(); search(input.value); }); }); picker.appendChild(list); document.body.appendChild(picker); // Place, then flip if we'd overflow the viewport. const r = picker.getBoundingClientRect(); let left = x, top = y; if (left + r.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - r.width - 8); if (top + r.height > window.innerHeight - 8) top = Math.max(8, window.innerHeight - r.height - 8); picker.style.left = left + 'px'; picker.style.top = top + 'px'; picker.style.visibility = ''; const onDown = (e) => { if (!picker.contains(e.target)) { picker.remove(); cleanup(); } }; const onEsc = (e) => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } }; const cleanup = () => { document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onEsc, true); }; setTimeout(() => { document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onEsc, true); }, 0); } 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); }); el.addEventListener('contextmenu', (e) => { e.preventDefault(); const id = el.dataset.cardId; if (!id) return; // Highlight the row so the user sees which card the picker // will move — mirrors the active-on-click behaviour. const i = parseInt(el.dataset.idx); if (!isNaN(i)) { active = i; updateActive(); } openBucketPicker(id, e.clientX, e.clientY); }); // Drag: only non-group, non-child cards if (el.draggable) { el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', el.dataset.cardId); e.dataTransfer.effectAllowed = 'move'; el.style.opacity = '0.4'; }); el.addEventListener('dragend', () => { el.style.opacity = ''; }); } // Drop target: any card can receive (to form group) el.addEventListener('dragover', e => { const dragId = e.dataTransfer.types.includes('text/plain') ? true : false; if (!dragId) return; if (el.dataset.cardId === e.dataTransfer.getData('text/plain')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; el.style.background = 'var(--t-raised)'; el.style.borderLeft = `3px solid var(--t-accent)`; }); el.addEventListener('dragleave', () => { el.style.background = ''; el.style.borderLeft = ''; }); el.addEventListener('drop', async e => { el.style.background = ''; el.style.borderLeft = ''; const dragId = e.dataTransfer.getData('text/plain'); const targetId = el.dataset.cardId; if (!dragId || !targetId || dragId === targetId) return; e.preventDefault(); await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: targetId } }); await window._reloadAll(); search(input.value); }); }); } 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); replaceToggle?.addEventListener('click', () => setReplaceMode(!replaceMode)); replaceInput?.addEventListener('input', updateReplaceState); replaceAllBtn?.addEventListener('click', async () => { const needle = input.value.trim(); const repl = replaceInput.value; if (!needle || !hits.length) return; let totalOcc = 0; const nLower = needle.toLowerCase(); for (const c of hits) { let pos = 0; const body = (c.body || '').toLowerCase(); while (true) { const i = body.indexOf(nLower, pos); if (i === -1) break; totalOcc++; pos = i + 1; } } const ok = await showDialog({ title: 'replace_all', message: `Replace ${totalOcc} occurrence${totalOcc !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${hits.length} card${hits.length !== 1 ? 's' : ''}?`, danger: true, okLabel: `Replace ${totalOcc}`, }); if (!ok) return; const freshCards = await api('api/cards') || []; const freshMap = new Map(freshCards.map(c => [c.id, c])); const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(escaped, 'gi'); let patched = 0; for (const c of hits) { const fresh = freshMap.get(c.id); const body = fresh?.body || c.body || ''; const newBody = body.replace(re, repl); if (newBody !== body) { await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body: newBody } }); patched++; } } replaceStatus.textContent = `done — ${patched} card${patched !== 1 ? 's' : ''} updated`; replaceStatus.style.color = 'var(--t-accent)'; replaceAllBtn.disabled = true; await window._reloadAll(); search(input.value); setTimeout(() => { replaceStatus.style.color = ''; }, 3000); });