Search view: read ?q from hash, call /api/search, group hits by kind with rank + space_id; sidebar filters for kinds and space_id; updates on Enter or filter change. Bumps package.json + server.js VERSION to 2.0.0-alpha.2 and pins the /health version assertion to match. CHANGELOG: full Plan 2 entry covering API surface, capability tiering, audit chain extension (approve/reject events), and the SPA shell. Security: adds safeHref() to dom.js and applies it everywhere an API-supplied URL becomes href / src (reference media block + reference source_url anchor + resource url anchor). javascript: and other non-http(s)/mailto schemes from agent-suggested content can no longer execute in the owner's browser. Plan 2 surface is feature-complete: 22/22 tasks landed, 185 tests across 43 files, SPA renders end-to-end including the suggest -> approve agent flow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
135 lines
4.5 KiB
JavaScript
135 lines
4.5 KiB
JavaScript
// Search results — read q from hash, group hits by kind, sidebar
|
|
// filters for kinds and space_id.
|
|
import { api } from '../api.js';
|
|
import { el, mount, clear } from '../dom.js';
|
|
import { navigate } from '../router.js';
|
|
|
|
const KINDS = ['page', 'ref', 'source_doc', 'message'];
|
|
const KIND_LABEL = { page: 'Pages', ref: 'References', source_doc: 'Source docs', message: 'Messages' };
|
|
const ROUTE_BY_KIND = {
|
|
page: id => '#/page/' + id,
|
|
ref: id => '#/ref/' + id,
|
|
source_doc: () => null,
|
|
message: () => null
|
|
};
|
|
|
|
function hitRow(h) {
|
|
const route = ROUTE_BY_KIND[h.kind];
|
|
const target = route ? route(h.id) : null;
|
|
return el('div', { class: 'search-hit' },
|
|
target
|
|
? el('a', { href: target }, h.title_or_snippet || '(untitled)')
|
|
: el('span', {}, h.title_or_snippet || '(untitled)'),
|
|
el('div', { class: 'hit-meta' },
|
|
'rank ' + (h.rank || 0).toFixed(3) +
|
|
(h.space_id ? ' · space ' + h.space_id.slice(0, 8) : ' · no space')
|
|
)
|
|
);
|
|
}
|
|
|
|
async function runSearch(resultsEl, statusEl, q, opts) {
|
|
if (!q) {
|
|
clear(resultsEl);
|
|
mount(statusEl, el('span', { class: 'muted' }, 'Type a query above to start.'));
|
|
return;
|
|
}
|
|
mount(statusEl, el('span', { class: 'muted' }, 'Searching …'));
|
|
const url = new URL('/api/search', location.origin);
|
|
url.searchParams.set('q', q);
|
|
if (opts.space_id) url.searchParams.set('space_id', opts.space_id);
|
|
if (opts.kinds.length && opts.kinds.length < KINDS.length) url.searchParams.set('kinds', opts.kinds.join(','));
|
|
url.searchParams.set('limit', '100');
|
|
let hits = [];
|
|
try { hits = await api.get(url.pathname + url.search); }
|
|
catch (e) {
|
|
clear(resultsEl);
|
|
mount(statusEl, el('span', { class: 'muted' }, 'Error: ' + e.message));
|
|
return;
|
|
}
|
|
mount(statusEl, el('span', { class: 'muted' }, hits.length + ' hits'));
|
|
|
|
const byKind = new Map();
|
|
for (const h of hits) {
|
|
if (!byKind.has(h.kind)) byKind.set(h.kind, []);
|
|
byKind.get(h.kind).push(h);
|
|
}
|
|
clear(resultsEl);
|
|
if (!hits.length) {
|
|
resultsEl.appendChild(el('p', { class: 'muted' }, 'No matches.'));
|
|
return;
|
|
}
|
|
for (const k of KINDS) {
|
|
const items = byKind.get(k);
|
|
if (!items?.length) continue;
|
|
resultsEl.appendChild(el('div', { class: 'search-group' },
|
|
el('div', { class: 'group-h' }, KIND_LABEL[k] + ' (' + items.length + ')'),
|
|
items.map(hitRow)
|
|
));
|
|
}
|
|
}
|
|
|
|
export async function render(main, ctx) {
|
|
const q = ctx.query.q || '';
|
|
let spaces = [];
|
|
try { spaces = await api.get('/api/spaces'); } catch { /* */ }
|
|
|
|
const queryInput = el('input', { type: 'text', placeholder: 'Search …', value: q, style: { width: '100%' } });
|
|
|
|
const opts = { kinds: [...KINDS], space_id: '' };
|
|
const kindCheckboxes = KINDS.map(k => {
|
|
const cb = el('input', {
|
|
type: 'checkbox', checked: true,
|
|
onchange: () => {
|
|
opts.kinds = KINDS.filter((kk, i) => kindCheckboxes[i].querySelector('input').checked);
|
|
runSearch(resultsEl, statusEl, queryInput.value, opts);
|
|
}
|
|
});
|
|
return el('label', { style: { display: 'block', cursor: 'pointer', padding: '2px 0' } },
|
|
cb, ' ', KIND_LABEL[k]
|
|
);
|
|
});
|
|
|
|
const spaceSelect = el('select', {
|
|
style: { width: '100%' },
|
|
onchange: () => {
|
|
opts.space_id = spaceSelect.value;
|
|
runSearch(resultsEl, statusEl, queryInput.value, opts);
|
|
}
|
|
});
|
|
spaceSelect.appendChild(el('option', { value: '' }, '(all spaces)'));
|
|
for (const s of spaces) spaceSelect.appendChild(el('option', { value: s.id }, s.name));
|
|
|
|
const statusEl = el('div', { class: 'muted', style: { marginBottom: '10px', fontSize: '12px' } });
|
|
const resultsEl = el('div');
|
|
|
|
queryInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const v = queryInput.value.trim();
|
|
navigate('/search?q=' + encodeURIComponent(v));
|
|
runSearch(resultsEl, statusEl, v, opts);
|
|
}
|
|
});
|
|
|
|
mount(main,
|
|
el('h1', { class: 'view-h1' }, 'Search'),
|
|
el('p', { class: 'view-sub muted' }, 'Full-text across pages, references, source docs, and messages.'),
|
|
el('div', { class: 'row' },
|
|
el('div', { class: 'card', style: { flex: '0 0 240px' } },
|
|
el('h3', {}, 'Filters'),
|
|
el('div', { class: 'muted', style: { fontSize: '11px', marginBottom: '4px' } }, 'Kinds'),
|
|
kindCheckboxes,
|
|
el('div', { class: 'muted', style: { fontSize: '11px', margin: '10px 0 4px' } }, 'Space'),
|
|
spaceSelect
|
|
),
|
|
el('div', { class: 'card' },
|
|
el('h3', {}, 'Query'),
|
|
queryInput,
|
|
statusEl,
|
|
resultsEl
|
|
)
|
|
)
|
|
);
|
|
|
|
runSearch(resultsEl, statusEl, q, opts);
|
|
}
|