package web
import (
"errors"
"html/template"
"net/http"
"sort"
"strings"
"munin/internal/entry"
"munin/internal/store"
)
// Wie viele Treffer die Suche über alle Projekte je Projekt zeigt. Genug, um
// das richtige Projekt zu erkennen; wer mehr will, sucht dort weiter.
const workspaceSearchPerProject = 5
// workspaceSearchData sind die Daten der Suche über alle Projekte.
type workspaceSearchData struct {
Query string
Groups []searchGroup
Hits int // Summe über alle Gruppen
Projects int // so viele Projekte wurden durchsucht
}
// searchGroup sind die Treffer eines Projekts.
type searchGroup struct {
Project store.Project
Items []entryListItem
More bool // es gibt mehr als workspaceSearchPerProject Treffer
top float64
}
// handleWorkspaceSearch sucht in allen Projekten des gewählten Workspace
// (AUSBAUSTUFEN.md 5.13) — für Workspace- und Plattform-Admins, die ohnehin
// alle Projekte sehen. Beantwortet „In welchem Projekt stand das nochmal?".
//
// Bewusst nur in der Weboberfläche und nur für diese Rollen: Am Zugriff der
// API-Keys ändert sich nichts, ein Key sieht weiter genau ein Projekt (siehe
// AUSBAUSTUFEN.md 4.9).
//
// Die Treffer kommen nach Projekt gruppiert. Eine gemeinsame Rangfolge über
// alle Projekte gäbe es nicht ehrlich: Jedes Projekt hat seinen eigenen
// BM25-Index, und deren Werte sind nicht vergleichbar. Die Projekte stehen
// aber nach ihrem besten Treffer sortiert — das passendste oben.
func (s *Server) handleWorkspaceSearch(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
ctx := r.Context()
query := strings.TrimSpace(r.URL.Query().Get("q"))
data := workspaceSearchData{Query: query}
if query != "" {
projects, err := ws.Store.ListProjects(ctx, sess.WorkspaceID)
if err != nil {
s.serverError(w, "Projekte konnten nicht geladen werden", err)
return
}
data.Projects = len(projects)
for _, p := range projects {
idx, err := ws.Search.For(ctx, p.ID)
if err != nil {
s.serverError(w, "Suchindex konnte nicht geladen werden", err)
return
}
// Einen mehr holen als gezeigt, um "weitere Treffer" anbieten zu
// können.
hits := idx.SearchFuzzy(query, workspaceSearchPerProject+1)
if len(hits) == 0 {
continue
}
group := searchGroup{Project: p, top: hits[0].Score}
for i, t := range hits {
if i == workspaceSearchPerProject {
group.More = true
break
}
e, err := ws.Store.EntryBySlug(ctx, p.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) {
continue // Index kennt einen Eintrag, den es nicht mehr gibt
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht geladen werden", err)
return
}
group.Items = append(group.Items, entryListItem{
Entry: e,
Snippet: template.HTML(entry.HighlightSnippet(e.Content, query)),
})
}
if len(group.Items) > 0 {
data.Groups = append(data.Groups, group)
data.Hits += len(group.Items)
}
}
sort.SliceStable(data.Groups, func(i, j int) bool {
return data.Groups[i].top > data.Groups[j].top
})
}
s.render(w, r, "search.html", http.StatusOK, pageData{
Title: "Suche in allen Projekten",
Section: "suche",
Data: data,
})
}