From d887bd6cab4aeb76bcd2a0cda8309584673b3408 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Tue, 21 Oct 2025 22:31:00 -0600 Subject: [PATCH] got Find Users to work --- database/user.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++ find.go | 36 +++++++++++++++- ui/templates.go | 28 +++++++++++++ ui/views/find.jet | 17 +++++++- 4 files changed, 184 insertions(+), 2 deletions(-) diff --git a/database/user.go b/database/user.go index 0c182c8..7b0dd95 100644 --- a/database/user.go +++ b/database/user.go @@ -139,6 +139,18 @@ const ( UserFlagMassMailOptOut = uint(2) ) +// Selectors for field and operator in user search. +const ( + SearchUserFieldName = 0 + SearchUserFieldDescription = 1 + SearchUserFieldFirstName = 2 + SearchUserFieldLastName = 3 + + SearchUserOperPrefix = 0 + SearchUserOperSubstring = 1 + SearchUserOperRegex = 2 +) + // userCache is the cache for User objects. var userCache *lru.TwoQueueCache = nil @@ -175,6 +187,15 @@ func (u *User) ContactInfo() (*ContactInfo, error) { return AmGetContactInfo(u.ContactID) } +// ContactInfo returns the contact info structure for the user, quietly. +func (u *User) ContactInfoQ() *ContactInfo { + if u.ContactID < 0 { + return nil + } + ci, _ := AmGetContactInfo(u.ContactID) + return ci +} + // SetContactID sets the contact ID of a user. func (u *User) SetContactID(cid int32) error { u.Mutex.Lock() @@ -748,3 +769,87 @@ func AmSetUserProperty(uid int32, ndx int32, val *string) error { } return err } + +/* AmSearchUsers searches for users matching certain criteria. + * Parameters: + * field - A value indicating which field to search: + * SearchUserFieldName - The user name. + * SearchUserFieldDescription - The user description. + * SearchUserFieldFirstName - The user's first name. + * SearchUserFieldLastName - The user's last name. + * oper - The operation to perform on the search field: + * SearchUserOperPrefix - The specified field has the string "term" as a prefix. + * SearchUserOperSubstring - The specified field contains the string "term". + * SearchUserOperRegex - The specified field matches the regular expression in "term". + * term - The search term, as specified above. + * offset - Number of communities to skip at beginning of list. + * max - Maximum number of communities to return. + * Returns: + * Array of User pointers representing the return elements. + * The total number of users matching this query (could be greater than max) + * Standard Go error status. + */ +func AmSearchUsers(field int, oper int, term string, offset int, max int) ([]*User, int, error) { + var queryPortion strings.Builder + switch field { + case SearchUserFieldName: + queryPortion.WriteString("u.username ") + case SearchUserFieldDescription: + queryPortion.WriteString("u.description ") + case SearchUserFieldFirstName: + queryPortion.WriteString("c.given_name ") + case SearchUserFieldLastName: + queryPortion.WriteString("c.family_name ") + default: + return nil, -1, errors.New("invalid field selector") + } + switch oper { + case SearchUserOperPrefix: + queryPortion.WriteString("LIKE '") + queryPortion.WriteString(util.SqlEscape(term, true)) + queryPortion.WriteString("%'") + case SearchUserOperSubstring: + queryPortion.WriteString("LIKE '%") + queryPortion.WriteString(util.SqlEscape(term, true)) + queryPortion.WriteString("%'") + case SearchUserOperRegex: + queryPortion.WriteString("REGEXP '") + queryPortion.WriteString(util.SqlEscape(term, false)) + queryPortion.WriteString("'") + default: + return nil, -1, errors.New("invalid operator selector") + } + q := queryPortion.String() + rs, err := amdb.Query("SELECT COUNT(*) FROM users u, contacts c WHERE u.contactid = c.contactid AND u.is_anon = 0 AND " + q) + if err != nil { + return nil, -1, err + } + if !rs.Next() { + return nil, -1, errors.New("internal error getting count") + } + var total int + rs.Scan(&total) + if total == 0 { + return make([]*User, 0), 0, nil + } + if offset > 0 { + rs, err = amdb.Query("SELECT u.uid FROM users u, contacts c WHERE u.contactid = c.contactid AND u.is_anon = 0 AND "+q+ + " ORDER BY u.username LIMIT ? OFFSET ?", max, offset) + } else { + rs, err = amdb.Query("SELECT u.uid FROM users u, contacts c WHERE u.contactid = c.contactid AND u.is_anon = 0 AND "+q+ + " ORDER BY u.username LIMIT ?", max) + } + if err != nil { + return nil, total, err + } + rc := make([]*User, 0, min(max, 10000)) + for rs.Next() { + var uid int32 + rs.Scan(&uid) + u, err := AmGetUser(uid) + if err == nil { + rc = append(rc, u) + } + } + return rc, total, nil +} diff --git a/find.go b/find.go index 0a1d86d..192265a 100644 --- a/find.go +++ b/find.go @@ -218,7 +218,41 @@ func Find(ctxt ui.AmContext) (string, any, error) { } } case "USR": - // TODO + var iField, iOper int + switch field { + case "name": + iField = database.SearchUserFieldName + case "descr": + iField = database.SearchUserFieldDescription + case "first": + iField = database.SearchUserFieldFirstName + case "last": + iField = database.SearchUserFieldLastName + default: + ctxt.VarMap().Set("errorMessage", "invalid parameter to find") + return "framed_template", "find.jet", nil + } + switch oper { + case "st": + iOper = database.SearchUserOperPrefix + case "in": + iOper = database.SearchUserOperSubstring + case "re": + iOper = database.SearchUserOperRegex + default: + ctxt.VarMap().Set("errorMessage", "invalid parameter to find") + return "framed_template", "find.jet", nil + } + var ulist []*database.User + ulist, total, err = database.AmSearchUsers(iField, iOper, term, ofs*listMax, listMax) + if err == nil { + if ulist == nil { + numResults = 0 + } else { + numResults = len(ulist) + ctxt.VarMap().Set("resultList", ulist) + } + } case "CAT": // TODO case "PST": diff --git a/ui/templates.go b/ui/templates.go index b107e94..c0f95d6 100644 --- a/ui/templates.go +++ b/ui/templates.go @@ -18,6 +18,7 @@ import ( "reflect" "regexp" "strconv" + "strings" "time" "git.erbosoft.com/amy/amsterdam/config" @@ -137,6 +138,32 @@ func displayMemberCount(a jet.Arguments) reflect.Value { return reflect.ValueOf(count) } +func displayFullName(a jet.Arguments) reflect.Value { + ci := a.Get(0).Convert(reflect.TypeFor[*database.ContactInfo]()).Interface().(*database.ContactInfo) + var rc strings.Builder + if ci.Prefix != nil && *ci.Prefix != "" { + rc.WriteString(*ci.Prefix) + rc.WriteString(" ") + } + if ci.GivenName != nil && *ci.GivenName != "" { + rc.WriteString(*ci.GivenName) + } + if ci.MiddleInit != nil && *ci.MiddleInit != "" { + rc.WriteString(" ") + rc.WriteString(*ci.MiddleInit) + rc.WriteString(".") + } + if ci.FamilyName != nil && *ci.FamilyName != "" { + rc.WriteString(" ") + rc.WriteString(*ci.FamilyName) + } + if ci.Suffix != nil && *ci.Suffix != "" { + rc.WriteString(" ") + rc.WriteString(*ci.Suffix) + } + return reflect.ValueOf(rc.String()) +} + // SetupTemplates is called to set up the template renderer after the configuration is loaded. func SetupTemplates() { views = jet.NewSet( @@ -154,6 +181,7 @@ func SetupTemplates() { views.AddGlobalFunc("ExtractCommunityLogo", extractCommunityLogo) views.AddGlobalFunc("DisplayActivity", displayActivity) views.AddGlobalFunc("DisplayMemberCount", displayMemberCount) + views.AddGlobalFunc("DisplayFullName", displayFullName) views.AddGlobalFunc("GetCountryList", func(jet.Arguments) reflect.Value { return reflect.ValueOf(util.AmCountryList()) diff --git a/ui/views/find.jet b/ui/views/find.jet index 90923a1..1fb5ba3 100644 --- a/ui/views/find.jet +++ b/ui/views/find.jet @@ -235,7 +235,22 @@ {{ else if mode == "USR" }} - TODO: I don't know USR yet + +
+ 🟣 +
+ +
+
+ {{ ci := rx.ContactInfoQ() }} + {{ DisplayFullName(ci) }}, from {{ ci.Locality }}, {{ ci.Region }} {{ ci.Country }} +
+
+
+
{{ else if mode == "CAT" }} TODO: I don't know CAT yet {{ else if mode == "PST" }}