OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / search.js

86 lines · javascript ref:
// 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(' ')
}