- 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.
168 lines
4.1 KiB
Go
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
|
|
}
|