mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
separated the search parts of ServerSearchReqDTO into ServerFilterReqDTO, factored out part of GetActivities that made the (filtering) query, which is then used in GetFilteredActivityCount with db.Count.
552 lines
16 KiB
Go
552 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
USER_DEFAULT_SORT_FIELD = "name"
|
|
USER_DEFAULT_SORT_ASCENDING = true
|
|
)
|
|
|
|
func (app *appContext) InvalidateUserCaches() {
|
|
app.InvalidateJellyfinCache()
|
|
app.InvalidateWebUserCache()
|
|
}
|
|
|
|
func (app *appContext) InvalidateJellyfinCache() {
|
|
app.jf.CacheExpiry = time.Now()
|
|
}
|
|
|
|
func (app *appContext) InvalidateWebUserCache() {
|
|
app.userCache.LastSync = time.Time{}
|
|
}
|
|
|
|
// UserCache caches the transport representation of users,
|
|
// complementing the built-in cache of the mediabrowser package.
|
|
// Synchronisation runs in the background and consumers receive
|
|
// old data for responsiveness unless an extended expiry time has passed.
|
|
// It also provides methods for sorting, searching and filtering server-side.
|
|
type UserCache struct {
|
|
Cache []respUser
|
|
Ref []*respUser
|
|
Sorted bool
|
|
LastSync time.Time
|
|
// After cache is this old, re-sync, but do it in the background and return the old cache.
|
|
SyncTimeout time.Duration
|
|
// After cache is this old, re-sync and wait for it and return the new cache.
|
|
WaitForSyncTimeout time.Duration
|
|
SyncLock sync.Mutex
|
|
Syncing bool
|
|
SortLock sync.Mutex
|
|
Sorting bool
|
|
}
|
|
|
|
func NewUserCache(syncTimeout, waitForSyncTimeout time.Duration) *UserCache {
|
|
return &UserCache{
|
|
SyncTimeout: syncTimeout,
|
|
WaitForSyncTimeout: waitForSyncTimeout,
|
|
}
|
|
}
|
|
|
|
// MaybeSync (maybe) syncs the cache, resulting in updated UserCache.Cache/.Ref/.Sorted.
|
|
// Only syncs if c.SyncTimeout duration has passed since last one.
|
|
// If c.WaitForSyncTimeout duration has passed, this will block until a sync is complete, otherwise it will sync in the background
|
|
// (expecting you to use the old cache data). Only one sync will run at a time.
|
|
func (c *UserCache) MaybeSync(app *appContext) error {
|
|
shouldWaitForSync := time.Now().After(c.LastSync.Add(c.WaitForSyncTimeout)) || c.Ref == nil || len(c.Ref) == 0
|
|
shouldSync := time.Now().After(c.LastSync.Add(c.SyncTimeout))
|
|
|
|
if !shouldSync {
|
|
return nil
|
|
}
|
|
|
|
syncStatus := make(chan error)
|
|
|
|
go func(status chan error, c *UserCache) {
|
|
c.SyncLock.Lock()
|
|
alreadySyncing := c.Syncing
|
|
// We're either already syncing or will be
|
|
c.Syncing = true
|
|
c.SyncLock.Unlock()
|
|
if !alreadySyncing {
|
|
users, err := app.jf.GetUsers(false)
|
|
if err != nil {
|
|
c.SyncLock.Lock()
|
|
c.Syncing = false
|
|
c.SyncLock.Unlock()
|
|
status <- err
|
|
return
|
|
}
|
|
cache := make([]respUser, len(users))
|
|
for i, jfUser := range users {
|
|
cache[i] = app.userSummary(jfUser)
|
|
}
|
|
ref := make([]*respUser, len(cache))
|
|
for i := range cache {
|
|
ref[i] = &(cache[i])
|
|
}
|
|
c.Cache = cache
|
|
c.Ref = ref
|
|
c.Sorted = false
|
|
c.LastSync = time.Now()
|
|
|
|
c.SyncLock.Lock()
|
|
c.Syncing = false
|
|
c.SyncLock.Unlock()
|
|
} else {
|
|
for c.Syncing {
|
|
continue
|
|
}
|
|
}
|
|
status <- nil
|
|
}(syncStatus, c)
|
|
|
|
if shouldWaitForSync {
|
|
err := <-syncStatus
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *UserCache) GetUserDTOs(app *appContext, sorted bool) ([]*respUser, error) {
|
|
if err := c.MaybeSync(app); err != nil {
|
|
return nil, err
|
|
}
|
|
if sorted && !c.Sorted {
|
|
c.SortLock.Lock()
|
|
alreadySorting := c.Sorting
|
|
c.Sorting = true
|
|
c.SortLock.Unlock()
|
|
if !alreadySorting {
|
|
c.Sort(c.Ref, USER_DEFAULT_SORT_FIELD, USER_DEFAULT_SORT_ASCENDING)
|
|
c.Sorted = true
|
|
c.SortLock.Lock()
|
|
c.Sorting = false
|
|
c.SortLock.Unlock()
|
|
} else {
|
|
for c.Sorting {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
return c.Ref, nil
|
|
}
|
|
|
|
// instead of making a Less for bools, just convert them to integers
|
|
// https://0x0f.me/blog/golang-compiler-optimization/
|
|
func bool2int(b bool) int {
|
|
var i int
|
|
if b {
|
|
i = 1
|
|
} else {
|
|
i = 0
|
|
}
|
|
return i
|
|
}
|
|
|
|
// Sorter compares the given field of two respUsers, returning -1 if a < b, 0 if a == b, 1 if a > b.
|
|
type Sorter func(a, b *respUser) int
|
|
|
|
// Allow sorting by respUser's struct fields (well, it's JSON-representation's fields)
|
|
// SortUsersBy returns a Sorter function, which compares the given field of two respUsers, returning -1 if a < b, 0 if a == b, 1 if a > b.
|
|
func SortUsersBy(field string) Sorter {
|
|
switch field {
|
|
case "id":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
|
|
}
|
|
case "name":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
|
}
|
|
case "email":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(b.Email))
|
|
}
|
|
case "notify_email":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(b.NotifyThroughEmail))
|
|
}
|
|
case "last_active":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(a.LastActive, b.LastActive)
|
|
}
|
|
case "admin":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.Admin), bool2int(b.Admin))
|
|
}
|
|
case "expiry":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(a.Expiry, b.Expiry)
|
|
}
|
|
case "disabled":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.Disabled), bool2int(b.Disabled))
|
|
}
|
|
case "telegram":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(b.Telegram))
|
|
}
|
|
case "notify_telegram":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(b.NotifyThroughTelegram))
|
|
}
|
|
case "discord":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(b.Discord))
|
|
}
|
|
case "discord_id":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(b.DiscordID))
|
|
}
|
|
case "notify_discord":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(b.NotifyThroughDiscord))
|
|
}
|
|
case "matrix":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(b.Matrix))
|
|
}
|
|
case "notify_matrix":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(b.NotifyThroughMatrix))
|
|
}
|
|
case "label":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(b.Label))
|
|
}
|
|
case "accounts_admin":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(b.AccountsAdmin))
|
|
}
|
|
case "referrals_enabled":
|
|
return func(a, b *respUser) int {
|
|
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(b.ReferralsEnabled))
|
|
}
|
|
}
|
|
panic(fmt.Errorf("got invalid field %s", field))
|
|
}
|
|
|
|
type CompareResult int
|
|
|
|
const (
|
|
Lesser CompareResult = -1
|
|
Equal CompareResult = 0
|
|
Greater CompareResult = 1
|
|
)
|
|
|
|
// One day i'll figure out Go generics
|
|
/*type FilterValue interface {
|
|
bool | string | DateAttempt
|
|
}*/
|
|
|
|
type DateAttempt struct {
|
|
Year *int `json:"year,omitempty"`
|
|
Month *int `json:"month,omitempty"`
|
|
Day *int `json:"day,omitempty"`
|
|
Hour *int `json:"hour,omitempty"`
|
|
Minute *int `json:"minute,omitempty"`
|
|
OffsetMinutesFromUTC *int `json:"offsetMinutesFromUTC,omitempty"`
|
|
}
|
|
|
|
// CompareWithOperator roughly compares a time.Time to a DateAttempt according to the given operator.
|
|
// **Considers zero-dates as invalid!** (i.e. any comparison to a subject.IsZero() will be false).
|
|
func (d DateAttempt) CompareWithOperator(subject time.Time, operator CompareResult) bool {
|
|
if subject.IsZero() {
|
|
return false
|
|
}
|
|
return d.Compare(subject) == int(operator)
|
|
}
|
|
|
|
// CompareUnixWithOperator roughly compares a unix timestamp to a DateAttempt according to the given operator.
|
|
// **Considers zero-dates as invalid!** (i.e. any comparison to a time.Unix(subject, 0).IsZero() or (subject == 0) will be false).
|
|
func (d DateAttempt) CompareUnixWithOperator(subject int64, operator CompareResult) bool {
|
|
if subject == 0 {
|
|
return false
|
|
}
|
|
subjectTime := time.Unix(subject, 0)
|
|
if subjectTime.IsZero() {
|
|
return false
|
|
}
|
|
return d.Compare(subjectTime) == int(operator)
|
|
}
|
|
|
|
// 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 time.Time) int {
|
|
// Remove anything more precise than a second
|
|
subject = subject.Truncate(time.Minute)
|
|
yy, mo, dd := subject.Date()
|
|
hh, mm, _ := subject.Clock()
|
|
if d.Year != nil {
|
|
yy = *d.Year
|
|
}
|
|
if d.Month != nil {
|
|
// Month in Javascript is zero-based, so we need to increment it
|
|
mo = time.Month((*d.Month) + 1)
|
|
}
|
|
if d.Day != nil {
|
|
dd = *d.Day
|
|
}
|
|
if d.Hour != nil {
|
|
hh = *d.Hour
|
|
}
|
|
if d.Minute != nil {
|
|
mm = *d.Minute
|
|
}
|
|
|
|
location := time.UTC
|
|
if d.OffsetMinutesFromUTC != nil {
|
|
location = time.FixedZone("", 60*(*d.OffsetMinutesFromUTC))
|
|
}
|
|
|
|
// FIXME: Transmit timezone in request maybe?
|
|
daAsTime := time.Date(yy, mo, dd, hh, mm, 0, 0, location)
|
|
comp := subject.Compare(daAsTime)
|
|
|
|
return comp
|
|
}
|
|
|
|
// 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.
|
|
type Filter func(*respUser) bool
|
|
|
|
// AsFilter returns a Filter function, which compares the queries value to the corresponding field's value in a passed respUser.
|
|
func (q QueryDTO) AsFilter() Filter {
|
|
operator := Equal
|
|
switch q.Operator {
|
|
case LesserOperator:
|
|
operator = Lesser
|
|
case EqualOperator:
|
|
operator = Equal
|
|
case GreaterOperator:
|
|
operator = Greater
|
|
}
|
|
|
|
switch q.Field {
|
|
case "id":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "name":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "email":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "notify_email":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "last_active":
|
|
switch q.Class {
|
|
case DateQuery:
|
|
return func(a *respUser) bool {
|
|
return q.Value.(DateAttempt).CompareUnixWithOperator(a.LastActive, operator)
|
|
}
|
|
case BoolQuery:
|
|
return func(a *respUser) bool {
|
|
val := a.LastActive != 0
|
|
if q.Value.(bool) == false {
|
|
val = !val
|
|
}
|
|
return val
|
|
}
|
|
}
|
|
case "admin":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.Admin), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "expiry":
|
|
switch q.Class {
|
|
case DateQuery:
|
|
return func(a *respUser) bool {
|
|
return q.Value.(DateAttempt).CompareUnixWithOperator(a.Expiry, operator)
|
|
}
|
|
case BoolQuery:
|
|
return func(a *respUser) bool {
|
|
val := a.Expiry != 0
|
|
if q.Value.(bool) == false {
|
|
val = !val
|
|
}
|
|
return val
|
|
}
|
|
}
|
|
case "disabled":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.Disabled), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "telegram":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "notify_telegram":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "discord":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "discord_id":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "notify_discord":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "matrix":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "notify_matrix":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "label":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(q.Value.(string))) == int(operator)
|
|
}
|
|
case "accounts_admin":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
case "referrals_enabled":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(q.Value.(bool))) == int(operator)
|
|
}
|
|
}
|
|
panic(fmt.Errorf("got invalid q.Field %s", q.Field))
|
|
}
|
|
|
|
// MatchesSearch checks (case-insensitively) if any string field in respUser includes the term string.
|
|
func (ru *respUser) MatchesSearch(term string) bool {
|
|
return (strings.Contains(ru.ID, term) ||
|
|
strings.Contains(strings.ToLower(ru.Name), term) ||
|
|
strings.Contains(strings.ToLower(ru.Label), term) ||
|
|
strings.Contains(strings.ToLower(ru.Email), term) ||
|
|
strings.Contains(strings.ToLower(ru.Discord), term) ||
|
|
strings.Contains(strings.ToLower(ru.Matrix), term) ||
|
|
strings.Contains(strings.ToLower(ru.Telegram), term))
|
|
}
|
|
|
|
// QueryClass is the class of a query (the datatype), i.e. bool, string or date.
|
|
type QueryClass string
|
|
|
|
const (
|
|
BoolQuery QueryClass = "bool"
|
|
StringQuery QueryClass = "string"
|
|
DateQuery QueryClass = "date"
|
|
)
|
|
|
|
// QueryOperator is the operator used for comparison in a filter, i.e. <, = or >.
|
|
type QueryOperator string
|
|
|
|
const (
|
|
LesserOperator QueryOperator = "<"
|
|
EqualOperator QueryOperator = "="
|
|
GreaterOperator QueryOperator = ">"
|
|
)
|
|
|
|
// QueryDTO is the transport representation of a Query, sent from the web app.
|
|
type QueryDTO struct {
|
|
Class QueryClass `json:"class"`
|
|
Field string `json:"field"`
|
|
Operator QueryOperator `json:"operator"`
|
|
// string | bool | DateAttempt
|
|
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
|
|
ServerFilterReqDTO
|
|
}
|
|
|
|
// ServerFilterReqDTO provides search terms and queries to a search or count route.
|
|
type ServerFilterReqDTO struct {
|
|
SearchTerms []string `json:"searchTerms"`
|
|
Queries []QueryDTO `json:"queries"`
|
|
}
|
|
|
|
// Filter reduces the passed slice of *respUsers
|
|
// by searching for each term of terms[] with respUser.MatchesSearch,
|
|
// and by evaluating Queries with Query.AsFilter().
|
|
func (c *UserCache) Filter(users []*respUser, terms []string, queries []QueryDTO) []*respUser {
|
|
filters := make([]Filter, len(queries))
|
|
for i, q := range queries {
|
|
filters[i] = q.AsFilter()
|
|
}
|
|
// FIXME: Properly consider pre-allocation size
|
|
out := make([]*respUser, 0, len(users)/4)
|
|
for i := range users {
|
|
match := true
|
|
for _, term := range terms {
|
|
if !users[i].MatchesSearch(term) {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if !match {
|
|
continue
|
|
}
|
|
for _, filter := range filters {
|
|
if filter == nil || !filter(users[i]) {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
out = append(out, users[i])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Sort sorts the given slice of of *respUsers in-place by the field name given, in ascending or descending order.
|
|
func (c *UserCache) Sort(users []*respUser, field string, ascending bool) {
|
|
slices.SortFunc(users, SortUsersBy(field))
|
|
if !ascending {
|
|
slices.Reverse(users)
|
|
}
|
|
}
|