diff --git a/activitysort.go b/activitysort.go new file mode 100644 index 0000000..7cd8af9 --- /dev/null +++ b/activitysort.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/timshannon/badgerhold/v4" +) + +const ( + ACTIVITY_DEFAULT_SORT_FIELD = "Time" + // This will be default anyway, as the default value of a bool field is false. + // ACTIVITY_DEFAULT_SORT_ASCENDING = false +) + +func activityDTONameToField(field string) string { + // Only "ID" and "Time" of these are actually searched by the UI. + // We support the rest though for other consumers of the API. + switch field { + case "id": + return "ID" + case "type": + return "Type" + case "user_id": + return "UserID" + case "username": + return "Username" + case "source_type": + return "SourceType" + case "source": + return "Source" + case "source_username": + return "SourceUsername" + case "invite_code": + return "InviteCode" + case "value": + return "Value" + case "time": + return "Time" + case "ip": + return "IP" + } + return "unknown" + // Only these query types actually search the ActivityDTO data. +} + +func activityTypeGetterNameToType(getter string) ActivityType { + switch getter { + case "accountCreation": + return ActivityCreation + case "accountDeletion": + return ActivityDeletion + case "accountDisabled": + return ActivityDisabled + case "accountEnabled": + return ActivityEnabled + case "contactLinked": + return ActivityContactLinked + case "contactUnlinked": + return ActivityContactUnlinked + case "passwordChange": + return ActivityChangePassword + case "passwordReset": + return ActivityResetPassword + case "inviteCreated": + return ActivityCreateInvite + case "inviteDeleted": + return ActivityDeleteInvite + } + return ActivityUnknown +} + +// 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:" 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") + if q.Operator != EqualOperator { + panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator)) + } + if q.Value.(bool) == true { + query = criterion.Eq(activityType) + } else { + query = criterion.Ne(activityType) + } + return query + } + + fieldName := activityDTONameToField(q.Field) + if fieldName == "unknown" { + panic("FIXME: Support all the weird queries of the web UI!") + } + criterion := query.And(fieldName) + + // FIXME: Deal with dates like we do in usercache.go + switch q.Operator { + case LesserOperator: + query = criterion.Lt(q.Value) + case EqualOperator: + query = criterion.Eq(q.Value) + case GreaterOperator: + query = criterion.Gt(q.Value) + } + return 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{} + // 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"} { + 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) { + field := ra.Field() + // _, ok := field.(string) + // if !ok { + // return false, fmt.Errorf("field not string: %s", fieldName) + // } + lower := strings.ToLower(field.(string)) + for _, term := range terms { + if strings.Contains(lower, term) { + return true, nil + } + } + return false, nil + })) + } + + return subQuery +} diff --git a/api-activities.go b/api-activities.go index d121aa4..eda15a4 100644 --- a/api-activities.go +++ b/api-activities.go @@ -6,32 +6,6 @@ import ( "github.com/timshannon/badgerhold/v4" ) -func stringToActivityType(v string) ActivityType { - switch v { - case "creation": - return ActivityCreation - case "deletion": - return ActivityDeletion - case "disabled": - return ActivityDisabled - case "enabled": - return ActivityEnabled - case "contactLinked": - return ActivityContactLinked - case "contactUnlinked": - return ActivityContactUnlinked - case "changePassword": - return ActivityChangePassword - case "resetPassword": - return ActivityResetPassword - case "createInvite": - return ActivityCreateInvite - case "deleteInvite": - return ActivityDeleteInvite - } - return ActivityUnknown -} - func activityTypeToString(v ActivityType) string { switch v { case ActivityCreation: @@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string { return "unknown" } +func stringToActivityType(v string) ActivityType { + switch v { + case "creation": + return ActivityCreation + case "deletion": + return ActivityDeletion + case "disabled": + return ActivityDisabled + case "enabled": + return ActivityEnabled + case "contactLinked": + return ActivityContactLinked + case "contactUnlinked": + return ActivityContactUnlinked + case "changePassword": + return ActivityChangePassword + case "resetPassword": + return ActivityResetPassword + case "createInvite": + return ActivityCreateInvite + case "deleteInvite": + return ActivityDeleteInvite + } + return ActivityUnknown +} + func stringToActivitySource(v string) ActivitySource { switch v { case "user": @@ -88,33 +88,36 @@ func activitySourceToString(v ActivitySource) string { // @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET. // @Produce json -// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters" // @Success 200 {object} GetActivitiesRespDTO // @Router /activity [post] // @Security Bearer // @tags Activity func (app *appContext) GetActivities(gc *gin.Context) { - req := GetActivitiesDTO{} + req := ServerSearchReqDTO{} gc.BindJSON(&req) - query := &badgerhold.Query{} - activityTypes := make([]any, len(req.Type)) - for i, v := range req.Type { - activityTypes[i] = stringToActivityType(v) - } - if len(activityTypes) != 0 { - query = badgerhold.Where("Type").In(activityTypes...) + if req.SortByField == "" { + req.SortByField = USER_DEFAULT_SORT_FIELD + } else { + req.SortByField = activityDTONameToField(req.SortByField) } + var query *badgerhold.Query + if len(req.SearchTerms) != 0 { + query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms) + } else { + query = &badgerhold.Query{} + } + + for _, q := range req.Queries { + query = q.AsDBQuery(query) + } + + query = query.SortBy(req.SortByField) if !req.Ascending { query = query.Reverse() } - query = query.SortBy("Time") - - if req.Limit == 0 { - req.Limit = 10 - } - query = query.Skip(req.Page * req.Limit).Limit(req.Limit) var results []Activity diff --git a/api-users.go b/api-users.go index 0ab670d..c061d01 100644 --- a/api-users.go +++ b/api-users.go @@ -936,14 +936,14 @@ func (app *appContext) GetUsers(gc *gin.Context) { // @Summary Get a paginated, searchable list of Jellyfin users. // @Produce json -// @Param getUsersReqDTO body getUsersReqDTO true "search / pagination parameters" +// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters" // @Success 200 {object} getUsersDTO // @Failure 500 {object} stringResponse // @Router /users [post] // @Security Bearer // @tags Users func (app *appContext) SearchUsers(gc *gin.Context) { - req := getUsersReqDTO{} + req := ServerSearchReqDTO{} gc.BindJSON(&req) if req.SortByField == "" { req.SortByField = USER_DEFAULT_SORT_FIELD diff --git a/models.go b/models.go index 7e6b31c..144bfcf 100644 --- a/models.go +++ b/models.go @@ -175,10 +175,6 @@ type PaginatedReqDTO struct { Ascending bool `json:"ascending"` } -type getUsersReqDTO struct { - ServerSearchReqDTO -} - type getUsersDTO struct { UserList []*respUser `json:"users"` LastPage bool `json:"last_page"` @@ -445,12 +441,6 @@ type ActivityDTO struct { IP string `json:"ip"` } -type GetActivitiesDTO struct { - // "SortByField" ignores, it's always time. - PaginatedReqDTO - Type []string `json:"type"` // Types of activity to get. Leave blank for all. -} - type GetActivitiesRespDTO struct { PaginatedDTO Activities []ActivityDTO `json:"activities"` diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 26d8a93..7716f8c 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -74,9 +74,9 @@ const queries = (): { [field: string]: QueryType } => { return { string: true, date: false }, - "date": { + "time": { name: window.lang.strings("date"), - getter: "date", + getter: "time", bool: false, string: false, date: true