api-cds-search/cmd/search/search.go

264 lines
5.6 KiB
Go
Raw Normal View History

2025-03-24 08:22:43 +00:00
/*
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 <https://www.gnu.org/licenses/>.
*/
2025-03-24 07:50:01 +00:00
package search
import (
2025-03-24 09:01:47 +00:00
"api-cds-search/cmd/model"
"api-cds-search/cmd/ui"
2025-03-24 07:50:01 +00:00
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/lithammer/fuzzysearch/fuzzy"
)
type fuzzyResult struct {
CDSViewTechnicalName string
hits int
2025-04-11 14:48:50 +00:00
perfectHits int
2025-03-24 07:50:01 +00:00
averageDistance float32
2025-04-11 14:48:50 +00:00
score float64
2025-03-24 07:50:01 +00:00
}
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, " ", ""))
}
2025-03-24 07:50:01 +00:00
var fuzzyResults []fuzzyResult
for viewName, targets := range searchTargets {
2025-04-11 14:48:50 +00:00
var (
totalHits, perfectHits int
totalDistance float32
)
2025-04-11 14:48:50 +00:00
totalDistance = 1
2025-03-24 07:50:01 +00:00
for i, splitSearchTerm := range splitSearchTerms {
if splitSearchTerm == "" {
continue
}
2025-04-11 14:48:50 +00:00
2025-03-24 07:50:01 +00:00
ranks := fuzzy.RankFindFold(splitSearchTerm, targets)
hits := len(ranks)
totalHits += hits
2025-04-11 14:48:50 +00:00
if hits == 0 {
totalDistance += 100
continue
}
2025-03-24 07:50:01 +00:00
var averageDistance float32 = 0
for _, rank := range ranks {
averageDistance += float32(rank.Distance)
if i >= len(splitSearchTerms)-2 && rank.Distance == 0 {
perfectHits += 1
}
2025-03-24 07:50:01 +00:00
}
totalDistance += averageDistance / float32(hits)
}
if totalHits > 0 {
fuzzyResults = append(fuzzyResults, fuzzyResult{
CDSViewTechnicalName: viewName,
hits: totalHits,
averageDistance: totalDistance,
2025-04-11 14:48:50 +00:00
perfectHits: perfectHits,
score: (float64(totalDistance) / float64(totalHits)) * (float64(len(targets)) / float64(totalHits)),
2025-03-24 07:50:01 +00:00
})
}
}
hits := len(fuzzyResults)
if hits == 0 {
return ErrNoResults
}
slices.SortFunc(fuzzyResults, func(a, b fuzzyResult) int {
2025-04-11 14:48:50 +00:00
if a.perfectHits < b.perfectHits {
return 1
} else if a.perfectHits > b.perfectHits {
return -1
}
2025-03-24 07:50:01 +00:00
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
}
2025-03-24 07:50:01 +00:00
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)
}
2025-03-24 07:50:01 +00:00
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
}
2025-03-24 07:50:01 +00:00
result := buffer.results[i]
cdsView, err := model.GetCDSViewModel(result.CDSViewTechnicalName)
if err != nil {
fmt.Println(err)
continue
}
resultsModel.AppendResultsModelViews(cdsView, result.score)
2025-03-24 07:50:01 +00:00
}
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()
}
}
}