OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-wiki / lib / search.js

106 lines · javascript ref:
import Hyperbee from 'hyperbee'

class SearchIndex {
  constructor (store) {
    this._store = store
    this._db = null
    this._ready = false
  }

  async ready () {
    if (this._ready) return

    const core = this._store.get({ name: 'wiki-search-index' })
    this._db = new Hyperbee(core, {
      keyEncoding: 'utf-8',
      valueEncoding: 'utf-8'
    })
    await this._db.ready()
    this._ready = true
  }

  async index (slug, content, meta = {}) {
    await this.ready()

    const title = meta.title || slug
    const text = `${title}\n${content}`.toLowerCase()

    // Extract unique words (simple tokenizer)
    const words = new Set(text.match(/\w{2,}/g) || [])

    // Store word → slug mappings
    for (const word of words) {
      const key = `word/${word}/${slug}`
      await this._db.put(key, title)
    }

    // Store slug → word count for ranking
    await this._db.put(`doc/${slug}`, JSON.stringify({
      title,
      wordCount: words.size,
      updated: meta.updated || Date.now()
    }))
  }

  async remove (slug) {
    await this.ready()

    // Remove all word entries for this slug
    const batch = this._db.batch()
    for await (const entry of this._db.createReadStream({
      gte: 'word/',
      lt: 'word/\xff'
    })) {
      if (entry.key.endsWith(`/${slug}`)) {
        await batch.del(entry.key)
      }
    }
    await batch.del(`doc/${slug}`)
    await batch.flush()
  }

  async search (query, limit = 20) {
    await this.ready()

    const terms = query.toLowerCase().match(/\w{2,}/g) || []
    if (terms.length === 0) return []

    // Find pages matching any term
    const scores = new Map()

    for (const term of terms) {
      for await (const entry of this._db.createReadStream({
        gte: `word/${term}/`,
        lt: `word/${term}/\xff`
      })) {
        const slug = entry.key.split('/').pop()
        scores.set(slug, (scores.get(slug) || 0) + 1)
      }
    }

    // Sort by score (more matching terms = higher)
    const results = [...scores.entries()]
      .sort((a, b) => b[1] - a[1])
      .slice(0, limit)

    // Fetch metadata
    const pages = []
    for (const [slug, score] of results) {
      const docEntry = await this._db.get(`doc/${slug}`)
      let meta = { title: slug }
      if (docEntry) {
        try { meta = JSON.parse(docEntry.value) } catch {}
      }
      pages.push({ slug, score, title: meta.title })
    }

    return pages
  }

  async close () {
    // Hyperbee closes with corestore
  }
}

export { SearchIndex }