ultisuite-backend/internal/api/contacts/match_score.go
R3D347HR4Y 556d5f416d Enhance API and configuration for contact discovery and public sharing
- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts.
- Implemented retry logic for handling missing DAV credentials during contact operations.
- Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations.
- Updated Nextcloud configuration to support public share links and improved error handling for public share permissions.
- Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback.
- Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
2026-06-06 20:27:02 +02:00

121 lines
2.8 KiB
Go

package contacts
import (
"sort"
"strings"
"unicode"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
func normalizeContactSearchText(value string) string {
var b strings.Builder
b.Grow(len(value))
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
if unicode.IsMark(r) {
continue
}
b.WriteRune(r)
}
return b.String()
}
func queryTokens(query string) []string {
n := normalizeContactSearchText(query)
if n == "" {
return nil
}
return strings.Fields(n)
}
func fieldMatchScore(haystack, needle string) float64 {
h := normalizeContactSearchText(haystack)
n := normalizeContactSearchText(needle)
if n == "" || !strings.Contains(h, n) {
return 0
}
if h == n {
return 1
}
if strings.HasPrefix(h, n) {
return 0.95 + 0.05*float64(len(n))/float64(max(len(h), 1))
}
for _, word := range strings.FieldsFunc(h, func(r rune) bool {
return r == ' ' || r == '@' || r == '.' || r == '_' || r == '+' || r == '-'
}) {
if word == "" {
continue
}
if strings.HasPrefix(word, n) {
return 0.88 + 0.07*float64(len(n))/float64(max(len(word), 1))
}
}
idx := strings.Index(h, n)
positionBonus := 1 - float64(idx)/float64(max(len(h), 1))*0.35
lengthBonus := float64(len(n)) / float64(max(len(h), 1))
return 0.42 + 0.28*positionBonus + 0.22*lengthBonus
}
func contactSearchFields(c nextcloud.Contact) []string {
out := make([]string, 0, 4)
for _, v := range []string{c.FullName, c.Email, c.Phone, c.Org} {
if strings.TrimSpace(v) != "" {
out = append(out, v)
}
}
return out
}
func contactQueryMatchScore(c nextcloud.Contact, query string) float64 {
fields := contactSearchFields(c)
tokens := queryTokens(query)
if len(tokens) == 0 {
return 0
}
var total float64
for _, token := range tokens {
best := 0.0
for _, field := range fields {
best = max(best, fieldMatchScore(field, token))
best = max(best, fieldMatchScore(field, query))
}
if best == 0 {
return 0
}
total += best
}
return total / float64(len(tokens))
}
func rankContactsByQuery(contacts []nextcloud.Contact, query string) []nextcloud.Contact {
query = strings.TrimSpace(query)
if query == "" {
return contacts
}
type scored struct {
contact nextcloud.Contact
score float64
}
matches := make([]scored, 0, len(contacts))
for _, c := range contacts {
if score := contactQueryMatchScore(c, query); score > 0 {
matches = append(matches, scored{contact: c, score: score})
}
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return strings.ToLower(matches[i].contact.FullName) < strings.ToLower(matches[j].contact.FullName)
})
out := make([]nextcloud.Contact, len(matches))
for i, m := range matches {
out[i] = m.contact
}
return out
}