mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data type and unmarshals into a DateAttempt if needed. mentionedUers, referrer and time matching implemented for activity search. Also, fixed multi-class queries (e.g. date -and- bool for last-active).
This commit is contained in:
137
activitysort.go
137
activitysort.go
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
@@ -70,17 +72,22 @@ func activityTypeGetterNameToType(getter string) ActivityType {
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
// andField appends to the existing query if not nil, and otherwise creates a new one.
|
||||
func andField(q *badgerhold.Query, field string) *badgerhold.Criterion {
|
||||
if q == nil {
|
||||
return badgerhold.Where(field)
|
||||
}
|
||||
return q.And(field)
|
||||
}
|
||||
|
||||
// AsDBQuery returns a mutated "query" filtering for the conditions in "q".
|
||||
func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||
if query == nil {
|
||||
query = &badgerhold.Query{}
|
||||
}
|
||||
// Special case for activity type:
|
||||
// In the app, there isn't an "activity:<fieldname>" query, but rather "<~fieldname>:true/false" queries.
|
||||
// For other API consumers, we also handle the former later.
|
||||
activityType := activityTypeGetterNameToType(q.Field)
|
||||
if activityType != ActivityUnknown {
|
||||
criterion := query.And("Type")
|
||||
criterion := andField(query, "Type")
|
||||
if q.Operator != EqualOperator {
|
||||
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
|
||||
}
|
||||
@@ -93,12 +100,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||
}
|
||||
|
||||
fieldName := activityDTONameToField(q.Field)
|
||||
if fieldName == "unknown" {
|
||||
panic("FIXME: Support all the weird queries of the web UI!")
|
||||
// Fail if unrecognized, or recognized as time (we handle this with DateAttempt.Compare separately).
|
||||
if fieldName == "unknown" || fieldName == "Time" {
|
||||
// Caller is expected to fall back to ActivityDBQueryFromSpecialField after this.
|
||||
return nil
|
||||
}
|
||||
criterion := query.And(fieldName)
|
||||
criterion := andField(query, fieldName)
|
||||
|
||||
// FIXME: Deal with dates like we do in usercache.go
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
query = criterion.Lt(q.Value)
|
||||
@@ -112,12 +120,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||
|
||||
// ActivityMatchesSearchAsDBBaseQuery returns a base query (which you should then apply other mutations to) matching the search "term" to Activities by searching all fields. Does not search the generated title like the web app.
|
||||
func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
|
||||
subQuery := &badgerhold.Query{}
|
||||
var baseQuery *badgerhold.Query = nil
|
||||
// I don't believe you can just do Where("*"), so instead run for each field.
|
||||
for _, fieldName := range []string{"ID", "Type", "UserID", "Username", "SourceType", "Source", "SourceUsername", "InviteCode", "Value", "IP"} {
|
||||
// FIXME: Match username and source_username and source_type and type
|
||||
for _, fieldName := range []string{"ID", "UserID", "Source", "InviteCode", "Value", "IP"} {
|
||||
criterion := badgerhold.Where(fieldName)
|
||||
// No case-insentive Contains method, so we use MatchFunc instead
|
||||
subQuery = subQuery.Or(criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
field := ra.Field()
|
||||
// _, ok := field.(string)
|
||||
// if !ok {
|
||||
@@ -130,8 +139,110 @@ func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}))
|
||||
})
|
||||
if baseQuery == nil {
|
||||
baseQuery = f
|
||||
} else {
|
||||
baseQuery = baseQuery.Or(f)
|
||||
}
|
||||
}
|
||||
|
||||
return subQuery
|
||||
return baseQuery
|
||||
}
|
||||
|
||||
func (act Activity) SourceIsUser() bool {
|
||||
return (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != ""
|
||||
}
|
||||
|
||||
func (act Activity) MustGetUsername(jf *mediabrowser.MediaBrowser) string {
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
return act.Value
|
||||
}
|
||||
if act.UserID == "" {
|
||||
return ""
|
||||
}
|
||||
// Don't care abt errors, user.Name will be blank in that case anyway
|
||||
user, _ := jf.UserByID(act.UserID, false)
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func (act Activity) MustGetSourceUsername(jf *mediabrowser.MediaBrowser) string {
|
||||
if !act.SourceIsUser() {
|
||||
return ""
|
||||
}
|
||||
// Don't care abt errors, user.Name will be blank in that case anyway
|
||||
user, _ := jf.UserByID(act.Source, false)
|
||||
return user.Name
|
||||
}
|
||||
|
||||
func ActivityDBQueryFromSpecialField(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
switch q.Field {
|
||||
case "mentionedUsers":
|
||||
query = matchMentionedUsersAsQuery(jf, query, q)
|
||||
case "actor":
|
||||
query = matchActorAsQuery(jf, query, q)
|
||||
case "referrer":
|
||||
query = matchReferrerAsQuery(jf, query, q)
|
||||
case "time":
|
||||
query = matchTimeAsQuery(query, q)
|
||||
default:
|
||||
panic(fmt.Errorf("unknown activity query field %s", q.Field))
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// matchMentionedUsersAsQuery is a custom match function for the "mentionedUsers" getter/query type.
|
||||
func matchMentionedUsersAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "UserID")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
usernames := act.MustGetUsername(jf) + " " + act.MustGetSourceUsername(jf)
|
||||
return strings.Contains(strings.ToLower(usernames), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// matchActorAsQuery is a custom match function for the "actor" getter/query type.
|
||||
func matchActorAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "SourceType")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
matchString := activitySourceToString(act.SourceType)
|
||||
if act.SourceType == ActivityAdmin || act.SourceType == ActivityUser && act.SourceIsUser() {
|
||||
matchString += " " + act.MustGetSourceUsername(jf)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(matchString), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// matchReferrerAsQuery is a custom match function for the "referrer" getter/query type.
|
||||
func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
criterion := andField(query, "Type")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
act := ra.Record().(*Activity)
|
||||
if act.Type == ActivityCreation || act.SourceType == ActivityUser || !act.SourceIsUser() {
|
||||
return false, nil
|
||||
}
|
||||
return strings.Contains(strings.ToLower(act.MustGetSourceUsername(jf)), strings.ToLower(q.Value.(string))), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
// mathcTimeAsQuery is a custom match function for the "time" getter/query type. Roughly matches the same way as the web app, and in usercache.go.
|
||||
func matchTimeAsQuery(query *badgerhold.Query, q QueryDTO) *badgerhold.Query {
|
||||
operator := Equal
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
operator = Lesser
|
||||
case EqualOperator:
|
||||
operator = Equal
|
||||
case GreaterOperator:
|
||||
operator = Greater
|
||||
}
|
||||
criterion := andField(query, "Time")
|
||||
query = criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
return q.Value.(DateAttempt).Compare(ra.Field().(time.Time)) == int(operator), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -106,11 +106,19 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
if len(req.SearchTerms) != 0 {
|
||||
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
||||
} else {
|
||||
query = &badgerhold.Query{}
|
||||
query = nil
|
||||
}
|
||||
|
||||
for _, q := range req.Queries {
|
||||
query = q.AsDBQuery(query)
|
||||
nq := q.AsDBQuery(query)
|
||||
if nq == nil {
|
||||
nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
|
||||
}
|
||||
query = nq
|
||||
}
|
||||
|
||||
if query == nil {
|
||||
query = &badgerhold.Query{}
|
||||
}
|
||||
|
||||
query = query.SortBy(req.SortByField)
|
||||
@@ -132,28 +140,21 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
resp.LastPage = len(results) != req.Limit
|
||||
for i, act := range results {
|
||||
resp.Activities[i] = ActivityDTO{
|
||||
ID: act.ID,
|
||||
Type: activityTypeToString(act.Type),
|
||||
UserID: act.UserID,
|
||||
SourceType: activitySourceToString(act.SourceType),
|
||||
Source: act.Source,
|
||||
InviteCode: act.InviteCode,
|
||||
Value: act.Value,
|
||||
Time: act.Time.Unix(),
|
||||
IP: act.IP,
|
||||
ID: act.ID,
|
||||
Type: activityTypeToString(act.Type),
|
||||
UserID: act.UserID,
|
||||
SourceType: activitySourceToString(act.SourceType),
|
||||
Source: act.Source,
|
||||
InviteCode: act.InviteCode,
|
||||
Value: act.Value,
|
||||
Time: act.Time.Unix(),
|
||||
IP: act.IP,
|
||||
Username: act.MustGetUsername(app.jf),
|
||||
SourceUsername: act.MustGetSourceUsername(app.jf),
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
resp.Activities[i].Username = act.Value
|
||||
// Username would've been in here, clear it to avoid confusion to the consumer
|
||||
resp.Activities[i].Value = ""
|
||||
} else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
|
||||
resp.Activities[i].Username = user.Name
|
||||
}
|
||||
|
||||
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
|
||||
user, err := app.jf.UserByID(act.Source, false)
|
||||
if err == nil {
|
||||
resp.Activities[i].SourceUsername = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -373,7 +373,8 @@ export class Search {
|
||||
this._c.search.oninput((null as Event));
|
||||
};
|
||||
}
|
||||
} else if (queryFormat.string) {
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
q = new StringQuery(queryFormat, split[1]);
|
||||
|
||||
q.onclick = () => {
|
||||
@@ -383,7 +384,8 @@ export class Search {
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
}
|
||||
} else if (queryFormat.date) {
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
||||
if (!isDate) continue;
|
||||
q = new DateQuery(queryFormat, op, parsedDate);
|
||||
|
||||
46
usercache.go
46
usercache.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -241,13 +242,12 @@ type DateAttempt struct {
|
||||
Minute *int `json:"minute,omitempty"`
|
||||
}
|
||||
|
||||
// Compares a Unix timestamp.
|
||||
// Compare roughly compares a time.Time to a DateAttempt.
|
||||
// We want to compare only the fields given in DateAttempt,
|
||||
// so we copy subjectDate and apply on those fields from this._value.
|
||||
func (d DateAttempt) Compare(subject int64) int {
|
||||
subjectTime := time.Unix(subject, 0)
|
||||
yy, mo, dd := subjectTime.Date()
|
||||
hh, mm, _ := subjectTime.Clock()
|
||||
func (d DateAttempt) Compare(subject time.Time) int {
|
||||
yy, mo, dd := subject.Date()
|
||||
hh, mm, _ := subject.Clock()
|
||||
if d.Year != nil {
|
||||
yy = *d.Year
|
||||
}
|
||||
@@ -264,7 +264,13 @@ func (d DateAttempt) Compare(subject int64) int {
|
||||
if d.Minute != nil {
|
||||
mm = *d.Minute
|
||||
}
|
||||
return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil))
|
||||
// FIXME: Transmit timezone in request maybe?
|
||||
return subject.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// CompareUnix roughly compares a unix timestamp to a DateAttempt.
|
||||
func (d DateAttempt) CompareUnix(subject int64) int {
|
||||
return d.Compare(time.Unix(subject, 0))
|
||||
}
|
||||
|
||||
// Filter returns true if a specific field in the passed respUser matches some internally defined value.
|
||||
@@ -301,7 +307,7 @@ func (q QueryDTO) AsFilter() Filter {
|
||||
}
|
||||
case "last_active":
|
||||
return func(a *respUser) bool {
|
||||
return q.Value.(DateAttempt).Compare(a.LastActive) == int(operator)
|
||||
return q.Value.(DateAttempt).CompareUnix(a.LastActive) == int(operator)
|
||||
}
|
||||
case "admin":
|
||||
return func(a *respUser) bool {
|
||||
@@ -309,7 +315,7 @@ func (q QueryDTO) AsFilter() Filter {
|
||||
}
|
||||
case "expiry":
|
||||
return func(a *respUser) bool {
|
||||
return q.Value.(DateAttempt).Compare(a.Expiry) == int(operator)
|
||||
return q.Value.(DateAttempt).CompareUnix(a.Expiry) == int(operator)
|
||||
}
|
||||
case "disabled":
|
||||
return func(a *respUser) bool {
|
||||
@@ -397,6 +403,30 @@ type QueryDTO struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows unmarshaling QueryDTO.Value into a DateAttempt type, rather than just a map.
|
||||
func (q *QueryDTO) UnmarshalJSON(data []byte) error {
|
||||
type _QueryDTO QueryDTO
|
||||
var temp _QueryDTO
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
*q = QueryDTO(temp)
|
||||
switch q.Value.(type) {
|
||||
case string:
|
||||
case bool:
|
||||
return nil
|
||||
case map[string]any:
|
||||
var do struct {
|
||||
Value DateAttempt `json:"value"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &do); err != nil {
|
||||
return err
|
||||
}
|
||||
q.Value = do.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerSearchReqDTO is a usual PaginatedReqDTO with added fields for searching and filtering.
|
||||
type ServerSearchReqDTO struct {
|
||||
PaginatedReqDTO
|
||||
|
||||
Reference in New Issue
Block a user