mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
fixed bugs, added extra text on "no results found" to suggest server searching, and conditionally disable the button based on search content and current sort. Activities page still broken. Also fixed up cache generation, only one should ever run now, as should sorting. Two time thresholds exist, one to trigger a re-sync but do it in the background (i.e. send the old one to the requester), and one to re-sync and wait for it.
427 lines
11 KiB
Go
427 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// FIXME: Follow mediabrowser, or make tuneable, or both
|
|
// After cache is this old, re-sync, but do it in the background and return the old cache.
|
|
WEB_USER_CACHE_SYNC = 30 * time.Second
|
|
// After cache is this old, re-sync and wait for it and return the new cache.
|
|
WEB_USER_CACHE_WAIT_FOR_SYNC = 5 * time.Minute
|
|
USER_DEFAULT_SORT_FIELD = "name"
|
|
USER_DEFAULT_SORT_ASCENDING = true
|
|
)
|
|
|
|
type UserCache struct {
|
|
Cache []respUser
|
|
Ref []*respUser
|
|
Sorted bool
|
|
LastSync time.Time
|
|
SyncLock sync.Mutex
|
|
Syncing bool
|
|
SortLock sync.Mutex
|
|
Sorting bool
|
|
}
|
|
|
|
// FIXME: If shouldSync, sync in background and return old version. If shouldWaitForSync, wait for sync and return new one.
|
|
// FIXME: If locked, just wait for unlock and return someone elses work.
|
|
func (c *UserCache) gen(app *appContext) error {
|
|
shouldWaitForSync := time.Now().After(c.LastSync.Add(WEB_USER_CACHE_WAIT_FOR_SYNC)) || c.Ref == nil || len(c.Ref) == 0
|
|
shouldSync := time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC))
|
|
|
|
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) Gen(app *appContext, sorted bool) ([]*respUser, error) {
|
|
if err := c.gen(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
|
|
}
|
|
|
|
// Returns -1 if respUser < value, 0 if equal, 1 is greater than
|
|
type Sorter func(a, b *respUser) int
|
|
|
|
// Allow sorting by respUser's struct fields (well, it's JSON-representation's fields)
|
|
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))
|
|
return nil
|
|
}
|
|
|
|
type Filter func(*respUser) bool
|
|
|
|
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"`
|
|
}
|
|
|
|
// Compares a Unix timestamp.
|
|
// 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()
|
|
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
|
|
}
|
|
return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil))
|
|
}
|
|
|
|
// FIXME: Consider using QueryDTO.Class rather than assuming type from name? Probably not worthwhile though.
|
|
func FilterUsersBy(field string, op QueryOperator, value any) Filter {
|
|
operator := Equal
|
|
switch op {
|
|
case LesserOperator:
|
|
operator = Lesser
|
|
case EqualOperator:
|
|
operator = Equal
|
|
case GreaterOperator:
|
|
operator = Greater
|
|
}
|
|
|
|
switch field {
|
|
case "id":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.ID), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "name":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "email":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Email), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "notify_email":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "last_active":
|
|
return func(a *respUser) bool {
|
|
return value.(DateAttempt).Compare(a.LastActive) == int(operator)
|
|
}
|
|
case "admin":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.Admin), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "expiry":
|
|
return func(a *respUser) bool {
|
|
return value.(DateAttempt).Compare(a.Expiry) == int(operator)
|
|
}
|
|
case "disabled":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.Disabled), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "telegram":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Telegram), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "notify_telegram":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughTelegram), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "discord":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Discord), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "discord_id":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.DiscordID), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "notify_discord":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughDiscord), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "matrix":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Matrix), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "notify_matrix":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.NotifyThroughMatrix), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "label":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(strings.ToLower(a.Label), strings.ToLower(value.(string))) == int(operator)
|
|
}
|
|
case "accounts_admin":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.AccountsAdmin), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
case "referrals_enabled":
|
|
return func(a *respUser) bool {
|
|
return cmp.Compare(bool2int(a.ReferralsEnabled), bool2int(value.(bool))) == int(operator)
|
|
}
|
|
}
|
|
panic(fmt.Errorf("got invalid field %s", field))
|
|
return nil
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
type QueryClass string
|
|
|
|
const (
|
|
BoolQuery QueryClass = "bool"
|
|
StringQuery QueryClass = "string"
|
|
DateQuery QueryClass = "date"
|
|
)
|
|
|
|
type QueryOperator string
|
|
|
|
const (
|
|
LesserOperator QueryOperator = "<"
|
|
EqualOperator QueryOperator = "="
|
|
GreaterOperator QueryOperator = ">"
|
|
)
|
|
|
|
type QueryDTO struct {
|
|
Class QueryClass `json:"class"`
|
|
Field string `json:"field"`
|
|
Operator QueryOperator `json:"operator"`
|
|
// string | bool | DateAttempt
|
|
Value any `json:"value"`
|
|
}
|
|
|
|
type ServerSearchReqDTO struct {
|
|
PaginatedReqDTO
|
|
SearchTerms []string `json:"searchTerms"`
|
|
Queries []QueryDTO `json:"queries"`
|
|
}
|
|
|
|
// Filter by AND-ing all search terms and queries.
|
|
func (c *UserCache) Filter(users []*respUser, terms []string, queries []QueryDTO) []*respUser {
|
|
filters := make([]Filter, len(queries))
|
|
for i, q := range queries {
|
|
filters[i] = FilterUsersBy(q.Field, q.Operator, q.Value)
|
|
}
|
|
// 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
|
|
}
|
|
|
|
func (c *UserCache) Sort(users []*respUser, field string, ascending bool) {
|
|
slices.SortFunc(users, SortUsersBy(field))
|
|
if !ascending {
|
|
slices.Reverse(users)
|
|
}
|
|
}
|