ultisuite-backend/internal/contacts/discovery/search_rank.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

168 lines
4.1 KiB
Go

package discovery
import (
"sort"
"strings"
"unicode"
)
func normalizeDiscoverySearchText(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 discoveryQueryTokens(query string) []string {
n := normalizeDiscoverySearchText(query)
if n == "" {
return nil
}
return strings.Fields(n)
}
func discoveryFieldMatchScore(haystack, needle string) float64 {
h := normalizeDiscoverySearchText(haystack)
n := normalizeDiscoverySearchText(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 appendNonEmptyFields(fields []string, values ...string) []string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
fields = append(fields, v)
}
}
return fields
}
func profileSearchFields(p Profile) []string {
fields := appendNonEmptyFields(nil, p.DisplayName, p.PrimaryEmail)
if p.EnrichedData != nil {
d := p.EnrichedData
fields = appendNonEmptyFields(fields,
d.FirstName, d.LastName, d.Company, d.Department, d.JobTitle, d.Website, d.Notes,
)
for _, e := range d.Emails {
fields = appendNonEmptyFields(fields, e.Value)
}
for _, ph := range d.Phones {
fields = appendNonEmptyFields(fields, ph.Value)
}
for _, a := range d.Addresses {
fields = appendNonEmptyFields(fields, a.Street, a.City, a.Region, a.PostalCode, a.Country)
}
}
for _, e := range p.AllEmails {
fields = appendNonEmptyFields(fields, e.Email, e.DisplayName)
}
for _, a := range p.DetectedInAccounts {
fields = appendNonEmptyFields(fields, a.AccountEmail, a.AccountName)
}
return fields
}
func profileGroupSearchFields(group ProfileGroup) []string {
fields := appendNonEmptyFields(nil, group.DisplayName, group.PrimaryEmail)
for _, p := range group.Profiles {
fields = append(fields, profileSearchFields(p)...)
}
if len(group.Profiles) == 0 {
fields = append(fields, profileSearchFields(group.Profile)...)
}
seen := make(map[string]struct{}, len(fields))
unique := make([]string, 0, len(fields))
for _, f := range fields {
key := normalizeDiscoverySearchText(f)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
unique = append(unique, f)
}
return unique
}
func profileGroupQueryMatchScore(group ProfileGroup, query string) float64 {
fields := profileGroupSearchFields(group)
tokens := discoveryQueryTokens(query)
if len(tokens) == 0 {
return 0
}
var total float64
for _, token := range tokens {
best := 0.0
for _, field := range fields {
best = max(best, discoveryFieldMatchScore(field, token))
best = max(best, discoveryFieldMatchScore(field, query))
}
if best == 0 {
return 0
}
total += best
}
return total / float64(len(tokens))
}
func rankProfileGroupsByQuery(groups []ProfileGroup, query string) []ProfileGroup {
query = strings.TrimSpace(query)
if query == "" {
return groups
}
type scored struct {
group ProfileGroup
score float64
}
matches := make([]scored, 0, len(groups))
for _, g := range groups {
if score := profileGroupQueryMatchScore(g, query); score > 0 {
matches = append(matches, scored{group: g, 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].group.DisplayName) < strings.ToLower(matches[j].group.DisplayName)
})
out := make([]ProfileGroup, len(matches))
for i, m := range matches {
out[i] = m.group
}
return out
}