// search — matching and ranking, kept PURE so the behaviour is testable with no index and no network.
//
// ⚠ WHAT THIS IS NOT. There is no inverted index here. favela's appview holds a few thousand records at
// most, so search is a SCAN over the existing subs with these predicates applied. That is an honest
// trade at this size and a bad one at a hundred thousand records — when the scan gets slow the fix is a
// real term index, not a faster scan. Stated here so the next reader does not mistake "it works" for
// "it scales".
//
// Matching rules, chosen to behave the way people expect rather than the way a regex is easiest:
// - case- and accent-insensitive, so "cafe" finds "Café"
// - ALL terms must appear (AND, not OR) — an OR search over a small corpus returns everything
// - a quoted "phrase" must appear contiguously
// - terms match on a word BOUNDARY, so "art" does not match "start" — substring matching on short
// terms is the single fastest way to make a search feel broken
// Strip diacritics and case. NFD splits a letter from its accent, then the combining marks are dropped.
export function fold (s) {
return String(s == null ? '' : s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
// A query -> { terms: string[], phrases: string[] }. Quoted runs become phrases; the rest split on
// whitespace. Empty/garbage yields empty arrays rather than throwing, so a stray quote cannot 500 a page.
export function parseQuery (q) {
const raw = String(q == null ? '' : q).trim()
if (!raw) return { terms: [], phrases: [] }
const phrases = []
const rest = raw.replace(/"([^"]+)"/g, (_, p) => { phrases.push(fold(p).trim()); return ' ' })
const terms = fold(rest).split(/\s+/).map((t) => t.trim()).filter(Boolean)
return { terms, phrases: phrases.filter(Boolean) }
}
const boundary = (hay, needle) => {
if (!needle) return false
let i = hay.indexOf(needle)
while (i !== -1) {
const before = i === 0 || !/[\p{L}\p{N}]/u.test(hay[i - 1])
if (before) return true // start-of-word is enough: "art" matches "artist", not "start"
i = hay.indexOf(needle, i + 1)
}
return false
}
// Does `text` satisfy the query? ALL terms and ALL phrases must be present.
export function matches (text, { terms, phrases }) {
if (!terms.length && !phrases.length) return false
const hay = fold(text)
for (const p of phrases) if (!hay.includes(p)) return false
for (const t of terms) if (!boundary(hay, t)) return false
return true
}
// A small relevance score, so an exact-ish hit outranks an incidental one. Deliberately simple: term
// frequency, with a bonus for a phrase hit and for matching near the start. Recency is applied by the
// caller (the index is already time-ordered), so this stays a pure function of the text.
export function score (text, { terms, phrases }) {
const hay = fold(text)
let s = 0
for (const p of phrases) { if (hay.includes(p)) s += 5 + (hay.indexOf(p) === 0 ? 2 : 0) }
for (const t of terms) {
let i = hay.indexOf(t); let n = 0
while (i !== -1) { n++; i = hay.indexOf(t, i + 1) }
if (n) s += Math.min(n, 3) + (hay.indexOf(t) === 0 ? 1 : 0)
}
return s
}
// The text of a post that search should see: the post text plus any image ALT text. Alt is included on
// purpose — someone who wrote "a photo of the harbour at dusk" has described a post that the post text
// itself may not describe at all, and excluding it makes those posts unfindable.
export function postText (record) {
if (!record) return ''
const parts = [record.text || '']
const e = record.embed
const imgs = e && e.$type === 'app.bsky.embed.images' ? e.images
: (e && e.$type === 'app.bsky.embed.recordWithMedia' && e.media && e.media.$type === 'app.bsky.embed.images' ? e.media.images : null)
for (const im of imgs || []) if (im && im.alt) parts.push(im.alt)
if (e && e.$type === 'app.bsky.embed.external' && e.external) parts.push(e.external.title || '', e.external.description || '')
return parts.filter(Boolean).join(' ')
}
// The text an ACTOR is searchable by. The DID is included so pasting one finds the person, which is a
// real thing operators do; the handle and display name are the ordinary paths.
export function actorText (did, profile) {
return [did || '', (profile && profile.displayName) || '', (profile && profile.handle) || '', (profile && profile.description) || ''].filter(Boolean).join(' ')
}