/* Copyright (C) 2025 snoutie Authors: snoutie (copyright@achtarmig.org) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package search import ( "api-cds-search/cmd/model" "api-cds-search/cmd/ui" "errors" "fmt" "net/http" "slices" "strconv" "strings" "sync" "time" "github.com/lithammer/fuzzysearch/fuzzy" ) type fuzzyResult struct { CDSViewTechnicalName string hits int perfectHits int averageDistance float32 score float64 } type resultBuffer struct { createdAt time.Time hits int mu sync.Mutex results []fuzzyResult } const pageStep = 12 var ( ErrNoBuffer = errors.New("no buffer") ErrNoResults = errors.New("no results") ) var searchTargets map[string][]string = make(map[string][]string) var resultsBuffer map[string]*resultBuffer = make(map[string]*resultBuffer) func Load() error { keywords, err := model.GetKeywordsModel() if err != nil { return err } for _, keyword := range *keywords { table := searchTargets[keyword.CDSViewTechnicalName] table = append(table, keyword.Keyword) searchTargets[keyword.CDSViewTechnicalName] = table } ticker := time.NewTicker(5 * time.Minute) go func() { for { select { case <-ticker.C: ClearOldBuffers() } } }() return nil } func GetSearchTerm(r *http.Request) string { return strings.ToLower(r.URL.Query().Get("q")) } func GetPage(r *http.Request) int { page, _ := strconv.Atoi(r.URL.Query().Get("p")) if page < 0 { page = 0 } return page } func GetBuffer(searchTerm string) (*resultBuffer, error) { buffer, exists := resultsBuffer[searchTerm] if !exists { return nil, ErrNoBuffer } return buffer, nil } func BuildBuffer(searchTerm string) error { if searchTerm == "" { return ErrNoResults } splitSearchTerms := strings.Split(searchTerm, " ") if len(splitSearchTerms) > 1 { splitSearchTerms = append(splitSearchTerms, searchTerm) splitSearchTerms = append(splitSearchTerms, strings.ReplaceAll(searchTerm, " ", "")) } var fuzzyResults []fuzzyResult for viewName, targets := range searchTargets { var ( totalHits, perfectHits int totalDistance float32 ) totalDistance = 1 for i, splitSearchTerm := range splitSearchTerms { if splitSearchTerm == "" { continue } ranks := fuzzy.RankFindFold(splitSearchTerm, targets) hits := len(ranks) totalHits += hits if hits == 0 { totalDistance += 100 continue } var averageDistance float32 = 0 for _, rank := range ranks { averageDistance += float32(rank.Distance) if i >= len(splitSearchTerms)-2 && rank.Distance == 0 { perfectHits += 1 } } totalDistance += averageDistance / float32(hits) } if totalHits > 0 { fuzzyResults = append(fuzzyResults, fuzzyResult{ CDSViewTechnicalName: viewName, hits: totalHits, averageDistance: totalDistance, perfectHits: perfectHits, score: (float64(totalDistance) / float64(totalHits)) * (float64(len(targets)) / float64(totalHits)), }) } } hits := len(fuzzyResults) if hits == 0 { return ErrNoResults } slices.SortFunc(fuzzyResults, func(a, b fuzzyResult) int { if a.perfectHits < b.perfectHits { return 1 } else if a.perfectHits > b.perfectHits { return -1 } if a.score > b.score { return 1 } else if a.score < b.score { return -1 } if a.CDSViewTechnicalName > b.CDSViewTechnicalName { return 1 } else if a.CDSViewTechnicalName < b.CDSViewTechnicalName { return -1 } return 0 }) resultsBuffer[searchTerm] = &resultBuffer{ createdAt: time.Now(), hits: hits, results: fuzzyResults, } return nil } func HandleSearch(w http.ResponseWriter, r *http.Request) { searchTerm := GetSearchTerm(r) buffer, err := GetBuffer(searchTerm) if err != nil { err := BuildBuffer(searchTerm) if err != nil { err := ui.Template.ExecuteTemplate(w, "search-placeholder-no-result", nil) if err != nil { fmt.Println(err) } return } buffer, _ = GetBuffer(searchTerm) } buffer.mu.Lock() defer buffer.mu.Unlock() page := GetPage(r) var maxPage int if buffer.hits <= pageStep { maxPage = 0 } else { maxPage = (buffer.hits / pageStep) } if page+1 > maxPage { page = maxPage } resultsModel := model.NewResultsModel() for i := page * pageStep; i < page*pageStep+pageStep; i++ { if i > len(buffer.results)-1 { break } result := buffer.results[i] cdsView, err := model.GetCDSViewModel(result.CDSViewTechnicalName) if err != nil { fmt.Println(err) continue } resultsModel.AppendResultsModelViews(cdsView, result.score) } resultsModel.SearchTerm = searchTerm resultsModel.CurrentPage = page resultsModel.MaxPage = maxPage err = ui.Template.ExecuteTemplate(w, "results", resultsModel) if err != nil { fmt.Println(err) return } } func ClearOldBuffers() { for key, buffer := range resultsBuffer { if time.Since(buffer.createdAt) >= time.Minute*10 { buffer.mu.Lock() delete(resultsBuffer, key) buffer.mu.Unlock() } } }