merge user "pagination"/server search

server-side search, user "pagination"/infinite scroll.
This commit is contained in:
Harvey Tindall
2025-05-27 15:06:58 +01:00
committed by GitHub
32 changed files with 2926 additions and 1174 deletions
+255
View File
@@ -0,0 +1,255 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/hrfee/mediabrowser"
"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"
}
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
}
// 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 {
// 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 := andField(query, "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)
// 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 := andField(query, fieldName)
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 {
var baseQuery *badgerhold.Query = nil
// I don't believe you can just do Where("*"), so instead run for each field.
// 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
f := 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
})
if baseQuery == nil {
baseQuery = f
} else {
baseQuery = baseQuery.Or(f)
}
}
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
}
sourceUsername := act.MustGetSourceUsername(jf)
if q.Class == BoolQuery {
val := sourceUsername != ""
if q.Value.(bool) == false {
val = !val
}
return val, nil
}
return strings.Contains(strings.ToLower(sourceUsername), 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).CompareWithOperator(ra.Field().(time.Time), operator), nil
})
return query
}
+67 -65
View File
@@ -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,71 +88,73 @@ 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([]interface{}, 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 = nil
}
for _, q := range req.Queries {
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)
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
err := app.storage.db.Find(&results, query)
if err != nil {
app.err.Printf(lm.FailedDBReadActivities, err)
}
resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
}
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
}
}
}
@@ -173,12 +175,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) {
// @Summary Returns the total number of activities stored in the database.
// @Produce json
// @Success 200 {object} GetActivityCountDTO
// @Success 200 {object} PageCountDTO
// @Router /activity/count [get]
// @Security Bearer
// @tags Activity
func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := GetActivityCountDTO{}
resp := PageCountDTO{}
var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil {
+71 -10
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/url"
"os"
"slices"
"strings"
"time"
@@ -337,8 +338,8 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
// FIXME: figure these out in a nicer way? this relies on the current ordering,
// which may not be fixed.
if discordEnabled {
if req.completeContactMethods[0].User != nil {
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
if req.completeContactMethods[0].User != nil {
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
}
if telegramEnabled && req.completeContactMethods[1].User != nil {
telegramUser = req.completeContactMethods[1].User.(*TelegramUser)
@@ -894,7 +895,25 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
}
// @Summary Get a list of Jellyfin users.
// @Summary Returns the total number of Jellyfin users.
// @Produce json
// @Success 200 {object} PageCountDTO
// @Router /users/count [get]
// @Security Bearer
// @tags Activity
func (app *appContext) GetUserCount(gc *gin.Context) {
resp := PageCountDTO{}
userList, err := app.userCache.GetUserDTOs(app, false)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
resp.Count = uint64(len(userList))
gc.JSON(200, resp)
}
// @Summary Get a list of -all- Jellyfin users.
// @Produce json
// @Success 200 {object} getUsersDTO
// @Failure 500 {object} stringResponse
@@ -903,19 +922,61 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// @tags Users
func (app *appContext) GetUsers(gc *gin.Context) {
var resp getUsersDTO
users, err := app.jf.GetUsers(false)
resp.UserList = make([]respUser, len(users))
// We're sending all users, so this is always true
resp.LastPage = true
var err error
resp.UserList, err = app.userCache.GetUserDTOs(app, true)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
i := 0
for _, jfUser := range users {
user := app.userSummary(jfUser)
resp.UserList[i] = user
i++
gc.JSON(200, resp)
}
// @Summary Get a paginated, searchable list of Jellyfin users.
// @Produce json
// @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 := ServerSearchReqDTO{}
gc.BindJSON(&req)
if req.SortByField == "" {
req.SortByField = USER_DEFAULT_SORT_FIELD
}
var resp getUsersDTO
userList, err := app.userCache.GetUserDTOs(app, req.SortByField == USER_DEFAULT_SORT_FIELD)
if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc)
return
}
var filtered []*respUser
if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
} else {
filtered = slices.Clone(userList)
}
if req.SortByField == USER_DEFAULT_SORT_FIELD {
if req.Ascending != USER_DEFAULT_SORT_ASCENDING {
slices.Reverse(filtered)
}
} else {
app.userCache.Sort(filtered, req.SortByField, req.Ascending)
}
startIndex := (req.Page * req.Limit)
if startIndex < len(filtered) {
endIndex := min(startIndex+req.Limit, len(filtered))
resp.UserList = filtered[startIndex:endIndex]
}
resp.LastPage = len(resp.UserList) != req.Limit
gc.JSON(200, resp)
}
+4
View File
@@ -188,6 +188,10 @@ func (app *appContext) loadConfig() error {
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
app.MustSetValue("jellyfin", "cache_timeout", "30")
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
+14
View File
@@ -65,6 +65,20 @@ sections:
type: number
value: 30
description: Timeout of user cache in minutes. Set to 0 to disable.
- setting: web_cache_async_timeout
name: User search cache asynchronous timeout (minutes)
requires_restart: true
advanced: true
type: number
value: 1
description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request."
- setting: web_cache_sync_timeout
name: User search cache synchronous timeout (minutes)
requires_restart: true
advanced: true
type: number
value: 10
description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
- setting: type
name: Server type
requires_restart: true
+16
View File
@@ -470,3 +470,19 @@ section.section:not(.\~neutral) {
}
}
:root {
/* seems to be the sweet spot */
--inside-input-base: -2.6rem;
/* thought --spacing would do the trick but apparently not */
--tailwind-spacing: 0.25rem;
}
/* places buttons inside a sibling input element (hopefully), based on the flex gap of the parent. */
.gap-1 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 1.0*var(--tailwind-spacing));
}
.gap-2 > .button.inside-input {
margin-left: calc(var(--inside-input-base) - 2.0*var(--tailwind-spacing));
}
+6
View File
@@ -27,6 +27,12 @@
right: 0;
}
.tooltip.above .content {
bottom: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
+1 -1
View File
@@ -43,7 +43,6 @@ require (
github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/mediabrowser v0.3.25
github.com/itchyny/timefmt-go v0.1.6
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.18.1
@@ -98,6 +97,7 @@ require (
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hrfee/mediabrowser v0.3.27 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
+2 -2
View File
@@ -205,8 +205,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hrfee/mediabrowser v0.3.25 h1:UxpSSTmr5q12gKfeOR2ommvA/xhrP2rxVWmpWjDPRUY=
github.com/hrfee/mediabrowser v0.3.25/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/hrfee/mediabrowser v0.3.27 h1:8bxPamBFLD1Xqy6pf6M3Oc5GUQ0iU/flO0S64G1AsIM=
github.com/hrfee/mediabrowser v0.3.27/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
+72 -45
View File
@@ -715,16 +715,24 @@
</div>
</div>
<div id="tab-accounts" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible flex flex-col gap-2">
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
</div>
<div class="flex flex-row align-middle w-full">
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<span class="button ~neutral @low center inside-input rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
@@ -733,13 +741,16 @@
</div>
</div>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span>
<div class="flex flex-row justify-between">
<div class="supra sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
</div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<div class="flex flex-row gap-2 flex-wrap">
<div id="accounts-sort-by-field"></div>
<span id="accounts-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
</div>
<div class="supra sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
@@ -774,7 +785,7 @@
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<div class="card @low accounts-header table-responsive">
<table class="table text-base leading-5">
<thead>
<tr>
@@ -802,19 +813,31 @@
</thead>
<tbody id="accounts-list"></tbody>
</table>
<div id="accounts-loader"></div>
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<button class="button ~neutral @low accounts-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
</div>
</div>
</div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low" id="accounts-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>
</div>
<div id="tab-activity" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible flex flex-col gap-2">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
@@ -824,10 +847,17 @@
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div>
</div>
<div class="flex flex-row align-middle w-full">
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
<span class="button ~neutral @low center inside-input rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<div class="tooltip left">
<button class="button ~info @low center h-full activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAll }}</span>
</button>
<span class="content sm">{{ .strings.searchAllRecords }}</span>
</div>
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
@@ -836,37 +866,34 @@
</div>
</div>
</div>
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="flex flex-row justify-between">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
<div class="supra sm flex flex-row gap-2" id="activity-record-counter"></div>
</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span>
<div class="flex flex-row gap-2 flex-wrap">
<span id="activity-filter-area" class="flex flex-row gap-2 flex-wrap"></span>
</div>
<div class="my-2">
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
<div class="unfocused h-[100%]" id="activity-not-found">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear flex flex-row gap-2">
<span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button>
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
</div>
</div>
<div class="flex justify-center">
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
</div>
</div>
<div id="activity-card-list"></div>
<div id="activity-loader"></div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
<button class="button ~info @low center activity-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
<i class="ri-search-line"></i>
<span>{{ .strings.searchAllRecords }}</span>
</button>
</div>
</div>
</div>
+4
View File
@@ -58,6 +58,7 @@
"disabled": "Disabled",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
"keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:",
@@ -136,6 +137,8 @@
"filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search",
"searchAll": "Search/sort all",
"searchAllRecords": "Search/sort all records (on server)",
"actions": "Actions",
"searchOptions": "Search Options",
"matchText": "Match Text",
@@ -190,6 +193,7 @@
"totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown",
"selectedRecords": "{n} Selected",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
+1 -1
View File
@@ -59,7 +59,7 @@ func logOutput() (closeFunc func(), err error) {
// Regex that removes ANSI color escape sequences. Used for outputting to log file and log cache.
var stripColors = func() *regexp.Regexp {
r, err := regexp.Compile("\\x1b\\[[0-9;]*m")
r, err := regexp.Compile(`\x1b\[[0-9;]*m`)
if err != nil {
log.Fatalf("Failed to compile color escape regexp: %v", err)
}
+1 -2
View File
@@ -118,8 +118,7 @@ const (
SetAdminNotify = "Set \"%s\" to %t for admin address \"%s\""
// *jellyseerr*.go
FailedGetUsers = "Failed to get user(s) from %s: %v"
// FIXME: Once done, look back at uses of FailedGetUsers for places where this would make more sense.
FailedGetUsers = "Failed to get user(s) from %s: %v"
FailedGetUser = "Failed to get user \"%s\" from %s: %v"
FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v"
FailedSyncContactMethods = "Failed to sync contact methods with %s: %v"
+8 -2
View File
@@ -134,6 +134,7 @@ type appContext struct {
pwrCaptchas map[string]Captcha
ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex
userCache *UserCache
}
func generateSecret(length int) (string, error) {
@@ -405,7 +406,7 @@ func start(asDaemon, firstCall bool) {
// Initialize jellyfin/emby connection
server := app.config.Section("jellyfin").Key("server").String()
cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30))
cacheTimeout := app.config.Section("jellyfin").Key("cache_timeout").MustInt()
stringServerType := app.config.Section("jellyfin").Key("type").String()
timeoutHandler := mediabrowser.NewNamedTimeoutHandler("Jellyfin", "\""+server+"\"", true)
if stringServerType == "emby" {
@@ -468,6 +469,11 @@ func start(asDaemon, firstCall bool) {
}
}
app.userCache = NewUserCache(
time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_async_timeout").MustInt()),
time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_sync_timeout").MustInt()),
)
// Since email depends on language, the email reload in loadConfig won't work first time.
// Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport.
app.email = NewEmailer(app)
@@ -527,7 +533,7 @@ func start(asDaemon, firstCall bool) {
// NOTE: The order in which these are placed in app.contactMethods matters.
// Add new ones to the end.
// FIXME: Add proxies.
// Proxies are added a little later through ContactMethodLinker[].SetTransport.
if discordEnabled {
app.discord, err = newDiscordDaemon(app)
if err != nil {
+15 -10
View File
@@ -164,8 +164,20 @@ type respUser struct {
ReferralsEnabled bool `json:"referrals_enabled"`
}
type PaginatedDTO struct {
LastPage bool `json:"last_page"`
}
type PaginatedReqDTO struct {
Limit int `json:"limit"`
Page int `json:"page"` // zero-indexed
SortByField string `json:"sortByField"`
Ascending bool `json:"ascending"`
}
type getUsersDTO struct {
UserList []respUser `json:"users"`
UserList []*respUser `json:"users"`
LastPage bool `json:"last_page"`
}
type ombiUser struct {
@@ -429,19 +441,12 @@ type ActivityDTO struct {
IP string `json:"ip"`
}
type GetActivitiesDTO struct {
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
Limit int `json:"limit"`
Page int `json:"page"` // zero-indexed
Ascending bool `json:"ascending"`
}
type GetActivitiesRespDTO struct {
PaginatedDTO
Activities []ActivityDTO `json:"activities"`
LastPage bool `json:"last_page"`
}
type GetActivityCountDTO struct {
type PageCountDTO struct {
Count uint64 `json:"count"`
}
+12
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0",
"a17t": "^0.10.1",
@@ -36,6 +37,12 @@
"esbuild": "^0.18.20"
}
},
"node_modules/@af-utils/scrollend-polyfill": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz",
"integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ==",
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -7293,6 +7300,11 @@
}
},
"dependencies": {
"@af-utils/scrollend-polyfill": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz",
"integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ=="
},
"@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+1
View File
@@ -17,6 +17,7 @@
},
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0",
"a17t": "^0.10.1",
+2
View File
@@ -183,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers)
api.GET(p+"/users", app.GetUsers)
api.GET(p+"/users/count", app.GetUserCount)
api.POST(p+"/users", app.SearchUsers)
api.POST(p+"/user", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
+17 -5
View File
@@ -14,12 +14,13 @@ import (
)
var (
names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab"}
names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab", "Graig", "Rhoda", "Tyler", "Quentin", "Melinda", "Zelma", "Jack", "Clifton", "Sherry", "Boyce", "Elma", "Jere", "Shelby", "Caitlin", "Bertie", "Mallory", "Thelma", "Charley", "Santo", "Merrill", "Royal", "Jefferson", "Ester", "Dee", "Susanna", "Adriana", "Alfonso", "Lillie", "Carmen", "Federico", "Ernie", "Kory", "Kimberly", "Donn", "Lilian", "Irvin", "Sherri", "Cordell", "Adrienne", "Edwin", "Serena", "Otis", "Latasha", "Johanna", "Clarence", "Noe", "Mindy", "Felix", "Audra"}
COUNT = 4000
DELAY = 1 * time.Millisecond
)
const (
PASSWORD = "test"
COUNT = 10
)
func main() {
@@ -57,6 +58,12 @@ func main() {
password = strings.TrimSuffix(password, "\n")
}
if countEnv := os.Getenv("COUNT"); countEnv != "" {
COUNT, _ = strconv.Atoi(countEnv)
}
fmt.Printf("Will generate %d users\n", COUNT)
jf, err := mediabrowser.NewServer(
mediabrowser.JellyfinServer,
server,
@@ -95,11 +102,12 @@ func main() {
rand.Seed(time.Now().Unix())
for i := 0; i < COUNT; i++ {
name := names[rand.Intn(len(names))] + strconv.Itoa(rand.Intn(100))
name := names[rand.Intn(len(names))] + strconv.Itoa(rand.Intn(500))
user, status, err := jf.NewUser(name, PASSWORD)
if (status != 200 && status != 201 && status != 204) || err != nil {
log.Fatalf("Failed to create user \"%s\" (%d): %+v\n", name, status, err)
log.Printf("Acc no %d: Failed to create user \"%s\" (%d): %+v\n", i, name, status, err)
continue
}
if rand.Intn(100) > 65 {
@@ -110,13 +118,17 @@ func main() {
user.Policy.IsDisabled = true
}
time.Sleep(DELAY / 4)
status, err = jf.SetPolicy(user.ID, user.Policy)
if (status != 200 && status != 201 && status != 204) || err != nil {
log.Fatalf("Failed to set policy for user \"%s\" (%d): %+v\n", name, status, err)
log.Fatalf("Acc no %d: Failed to set policy for user \"%s\" (%d): %+v\n", i, name, status, err)
}
if rand.Intn(100) > 20 {
time.Sleep(DELAY / 4)
jfTemp.Authenticate(name, PASSWORD)
}
log.Printf("Acc %d done\n", i)
time.Sleep(DELAY / 4)
}
}
+11 -4
View File
@@ -127,7 +127,7 @@ let isInviteURL = window.invites.isInviteURL();
let isAccountURL = accounts.isAccountURL();
// load tabs
const tabs: { id: string, url: string, reloader: () => void }[] = [
const tabs: { id: string, url: string, reloader: () => void, unloader?: () => void }[] = [
{
id: "invites",
url: "",
@@ -148,12 +148,19 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [
// Don't keep loading the same item on every tab refresh
isAccountURL = false;
}
accounts.bindPageEvents();
}),
unloader: accounts.unbindPageEvents
},
{
id: "activity",
url: "activity",
reloader: activity.reload
reloader: () => {
activity.reload()
activity.bindPageEvents();
},
unloader: activity.unbindPageEvents
},
{
id: "settings",
@@ -167,7 +174,7 @@ const defaultTab = tabs[0];
window.tabs = new Tabs();
for (let tab of tabs) {
window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader);
window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader, tab.unloader || null);
}
let matchedTab = false
@@ -188,7 +195,7 @@ login.onLogin = () => {
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
reloadProfileNames();
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
setInterval(() => { window.invites.reload(); accounts.reloadIfNotInScroll(); }, 30*1000);
// Triggers pre and post funcs, even though we're already on that page
window.tabs.switch(window.tabs.current);
}
-1
View File
@@ -7,7 +7,6 @@ const logNormal = document.getElementById("log-normal") as HTMLInputElement;
const logSanitized = document.getElementById("log-sanitized") as HTMLInputElement;
const buttonChange = (type: string) => {
console.log("RUN");
if (type == "normal") {
logSanitized.classList.add("unfocused");
logNormal.classList.remove("unfocused");
+532 -500
View File
File diff suppressed because it is too large Load Diff
+231 -368
View File
@@ -1,10 +1,14 @@
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
import { _get, _post, _delete, toDateString } from "../modules/common.js";
import { SearchConfiguration, QueryType, SearchableItem, SearchableItems, SearchableItemDataAttribute } from "../modules/search.js";
import { accountURLEvent } from "../modules/accounts.js";
import { inviteURLEvent } from "../modules/invites.js";
import { PaginatedList } from "./list.js";
declare var window: GlobalWindow;
const ACTIVITY_DEFAULT_SORT_FIELD = "time";
const ACTIVITY_DEFAULT_SORT_ASCENDING = false;
export interface activity {
id: string;
type: string;
@@ -32,6 +36,124 @@ var activityTypeMoods = {
"deleteInvite": -1
};
// window.lang doesn't exist at page load, so I made this a function that's invoked by activityList.
const queries = (): { [field: string]: QueryType } => { return {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
bool: false,
string: true,
date: false
},
"title": {
name: window.lang.strings("title"),
getter: "title",
bool: false,
string: true,
date: false,
localOnly: true
},
"user": {
name: window.lang.strings("usersMentioned"),
getter: "mentionedUsers",
bool: false,
string: true,
date: false
},
"actor": {
name: window.lang.strings("actor"),
description: window.lang.strings("actorDescription"),
getter: "actor",
bool: false,
string: true,
date: false
},
"referrer": {
name: window.lang.strings("referrer"),
getter: "referrer",
bool: true,
string: true,
date: false
},
"time": {
name: window.lang.strings("date"),
getter: "time",
bool: false,
string: false,
date: true
},
"account-creation": {
name: window.lang.strings("accountCreationFilter"),
getter: "accountCreation",
bool: true,
string: false,
date: false
},
"account-deletion": {
name: window.lang.strings("accountDeletionFilter"),
getter: "accountDeletion",
bool: true,
string: false,
date: false
},
"account-disabled": {
name: window.lang.strings("accountDisabledFilter"),
getter: "accountDisabled",
bool: true,
string: false,
date: false
},
"account-enabled": {
name: window.lang.strings("accountEnabledFilter"),
getter: "accountEnabled",
bool: true,
string: false,
date: false
},
"contact-linked": {
name: window.lang.strings("contactLinkedFilter"),
getter: "contactLinked",
bool: true,
string: false,
date: false
},
"contact-unlinked": {
name: window.lang.strings("contactUnlinkedFilter"),
getter: "contactUnlinked",
bool: true,
string: false,
date: false
},
"password-change": {
name: window.lang.strings("passwordChangeFilter"),
getter: "passwordChange",
bool: true,
string: false,
date: false
},
"password-reset": {
name: window.lang.strings("passwordResetFilter"),
getter: "passwordReset",
bool: true,
string: false,
date: false
},
"invite-created": {
name: window.lang.strings("inviteCreatedFilter"),
getter: "inviteCreated",
bool: true,
string: false,
date: false
},
"invite-deleted": {
name: window.lang.strings("inviteDeletedFilter"),
getter: "inviteDeleted",
bool: true,
string: false,
date: false
}
}};
// var moodColours = ["~warning", "~neutral", "~urge"];
export var activityReload = new CustomEvent("activity-reload");
@@ -232,7 +354,10 @@ export class Activity implements activity, SearchableItem {
}
get id(): string { return this._act.id; }
set id(v: string) { this._act.id = v; }
set id(v: string) {
this._act.id = v;
this._card.setAttribute(SearchableItemDataAttribute, v);
}
get user_id(): string { return this._act.user_id; }
set user_id(v: string) { this._act.user_id = v; }
@@ -258,6 +383,7 @@ export class Activity implements activity, SearchableItem {
this._card = document.createElement("div");
this._card.classList.add("card", "@low", "my-2");
this._card.innerHTML = `
<div class="flex flex-col md:flex-row justify-between mb-2">
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span>
@@ -346,343 +472,127 @@ export class Activity implements activity, SearchableItem {
asElement = () => { return this._card; };
}
interface ActivitiesDTO {
interface ActivitiesReqDTO extends PaginatedReqDTO {
type: string[];
};
interface ActivitiesDTO extends paginatedDTO {
activities: activity[];
last_page: boolean;
}
export class activityList {
private _activityList: HTMLElement;
private _activities: { [id: string]: Activity } = {};
private _ordering: string[] = [];
private _filterArea = document.getElementById("activity-filter-area");
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
private _notFoundPanel = document.getElementById("activity-not-found");
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
private _loader = document.getElementById("activity-loader");
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
private _keepSearchingButton = document.getElementById("activity-keep-searching");
export class activityList extends PaginatedList {
protected _container: HTMLElement;
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
private _totalRecords = document.getElementById("activity-total-records");
private _loadedRecords = document.getElementById("activity-loaded-records");
private _shownRecords = document.getElementById("activity-shown-records");
private _total: number;
private _loaded: number;
private _shown: number;
get total(): number { return this._total; }
set total(v: number) {
this._total = v;
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
}
protected _ascending: boolean;
get loaded(): number { return this._loaded; }
set loaded(v: number) {
this._loaded = v;
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
}
get activities(): { [id: string]: Activity } { return this._search.items as { [id: string]: Activity }; }
// set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
get shown(): number { return this._shown; }
set shown(v: number) {
this._shown = v;
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
}
private _search: Search;
private _ascending: boolean;
private _hasLoaded: boolean;
private _lastLoad: number;
private _page: number = 0;
private _lastPage: boolean;
setVisibility = (activities: string[], visible: boolean) => {
this._activityList.textContent = ``;
for (let id of this._ordering) {
if (visible && activities.indexOf(id) != -1) {
this._activityList.appendChild(this._activities[id].asElement());
} else if (!visible && activities.indexOf(id) == -1) {
this._activityList.appendChild(this._activities[id].asElement());
}
}
}
reload = () => {
this._lastLoad = Date.now();
this._lastPage = false;
this._loadMoreButton.textContent = window.lang.strings("loadMore");
this._loadMoreButton.disabled = false;
this._loadAllButton.classList.remove("unfocused");
this._loadAllButton.disabled = false;
this.total = 0;
this.loaded = 0;
this.shown = 0;
// this._page = 0;
let limit = 10;
if (this._page != 0) {
limit *= this._page+1;
};
let send = {
"type": [],
"limit": limit,
"page": 0,
"ascending": this.ascending
}
_get("/activity/count", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
this.total = req.response["count"] as number;
});
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
this._hasLoaded = true;
// Allow refreshes every 15s
this._refreshButton.disabled = true;
setTimeout(() => this._refreshButton.disabled = false, 15000);
let resp = req.response as ActivitiesDTO;
// FIXME: Don't destroy everything each reload!
this._activities = {};
this._ordering = [];
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._ordering.push(act.id);
}
this._search.items = this._activities;
this._search.ordering = this._ordering;
this.loaded = this._ordering.length;
if (this._search.inSearch) {
this._search.onSearchBoxChange(true);
this._loadAllButton.classList.remove("unfocused");
} else {
this.shown = this.loaded;
this.setVisibility(this._ordering, true);
this._loadAllButton.classList.add("unfocused");
this._notFoundPanel.classList.add("unfocused");
}
}, true);
}
loadMore = (callback?: () => void, loadAll: boolean = false) => {
this._lastLoad = Date.now();
this._loadMoreButton.disabled = true;
// this._loadAllButton.disabled = true;
const timeout = setTimeout(() => {
this._loadMoreButton.disabled = false;
// this._loadAllButton.disabled = false;
}, 1000);
this._page += 1;
let send = {
"type": [],
"limit": 10,
"page": this._page,
"ascending": this._ascending
};
// this._activityList.classList.add("unfocused");
// addLoader(this._loader, false, true);
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
let resp = req.response as ActivitiesDTO;
this._lastPage = resp.last_page;
if (this._lastPage) {
clearTimeout(timeout);
this._loadMoreButton.disabled = true;
removeLoader(this._loadAllButton);
this._loadAllButton.classList.add("unfocused");
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
}
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._ordering.push(act.id);
}
// this._search.items = this._activities;
// this._search.ordering = this._ordering;
this.loaded = this._ordering.length;
if (this._search.inSearch || loadAll) {
if (this._lastPage) {
loadAll = false;
constructor() {
super({
loader: document.getElementById("activity-loader"),
loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement,
loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement,
refreshButton: document.getElementById("activity-refresh") as HTMLButtonElement,
filterArea: document.getElementById("activity-filter-area"),
searchOptionsHeader: document.getElementById("activity-search-options-header"),
searchBox: document.getElementById("activity-search") as HTMLInputElement,
recordCounter: document.getElementById("activity-record-counter"),
totalEndpoint: "/activity/count",
getPageEndpoint: "/activity",
itemsPerPage: 20,
maxItemsLoadedForSearch: 200,
appendNewItems: (resp: paginatedDTO) => {
let ordering: string[] = this._search.ordering;
for (let act of ((resp as ActivitiesDTO).activities || [])) {
this.activities[act.id] = new Activity(act);
ordering.push(act.id);
}
this._search.setOrdering(ordering, this._c.defaultSortField, this.ascending);
},
replaceWithNewItems: (resp: paginatedDTO) => {
// FIXME: Implement updates to existing elements, rather than just wiping each time.
// Remove existing items
for (let id of Object.keys(this.activities)) {
delete this.activities[id];
}
// And wipe their ordering
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
this._c.appendNewItems(resp);
},
defaultSortField: ACTIVITY_DEFAULT_SORT_FIELD,
defaultSortAscending: ACTIVITY_DEFAULT_SORT_ASCENDING,
pageLoadCallback: (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
this._search.onSearchBoxChange(true, loadAll);
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
}
});
this._container = document.getElementById("activity-card-list")
document.addEventListener("activity-reload", () => this.reload());
if (callback) callback();
// removeLoader(this._loader);
// this._activityList.classList.remove("unfocused");
}, true);
let searchConfig: SearchConfiguration = {
filterArea: this._c.filterArea,
// Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
// sortingByButton: this._sortingByButton,
searchOptionsHeader: this._c.searchOptionsHeader,
notFoundPanel: document.getElementById("activity-not-found"),
notFoundLocallyText: document.getElementById("activity-no-local-results"),
search: this._c.searchBox,
clearSearchButtonSelector: ".activity-search-clear",
serverSearchButtonSelector: ".activity-search-server",
queries: queries(),
setVisibility: null,
filterList: document.getElementById("activity-filter-list"),
// notFoundCallback: this._notFoundCallback,
onSearchCallback: null,
searchServer: null,
clearServerSearch: null,
}
this.initSearch(searchConfig);
this.ascending = this._c.defaultSortAscending;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
}
private _queries: { [field: string]: QueryType } = {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
bool: false,
string: true,
date: false
},
"title": {
name: window.lang.strings("title"),
getter: "title",
bool: false,
string: true,
date: false
},
"user": {
name: window.lang.strings("usersMentioned"),
getter: "mentionedUsers",
bool: false,
string: true,
date: false
},
"actor": {
name: window.lang.strings("actor"),
description: window.lang.strings("actorDescription"),
getter: "actor",
bool: false,
string: true,
date: false
},
"referrer": {
name: window.lang.strings("referrer"),
getter: "referrer",
bool: true,
string: true,
date: false
},
"date": {
name: window.lang.strings("date"),
getter: "date",
bool: false,
string: false,
date: true
},
"account-creation": {
name: window.lang.strings("accountCreationFilter"),
getter: "accountCreation",
bool: true,
string: false,
date: false
},
"account-deletion": {
name: window.lang.strings("accountDeletionFilter"),
getter: "accountDeletion",
bool: true,
string: false,
date: false
},
"account-disabled": {
name: window.lang.strings("accountDisabledFilter"),
getter: "accountDisabled",
bool: true,
string: false,
date: false
},
"account-enabled": {
name: window.lang.strings("accountEnabledFilter"),
getter: "accountEnabled",
bool: true,
string: false,
date: false
},
"contact-linked": {
name: window.lang.strings("contactLinkedFilter"),
getter: "contactLinked",
bool: true,
string: false,
date: false
},
"contact-unlinked": {
name: window.lang.strings("contactUnlinkedFilter"),
getter: "contactUnlinked",
bool: true,
string: false,
date: false
},
"password-change": {
name: window.lang.strings("passwordChangeFilter"),
getter: "passwordChange",
bool: true,
string: false,
date: false
},
"password-reset": {
name: window.lang.strings("passwordResetFilter"),
getter: "passwordReset",
bool: true,
string: false,
date: false
},
"invite-created": {
name: window.lang.strings("inviteCreatedFilter"),
getter: "inviteCreated",
bool: true,
string: false,
date: false
},
"invite-deleted": {
name: window.lang.strings("inviteDeletedFilter"),
getter: "inviteDeleted",
bool: true,
string: false,
date: false
}
reload = (callback?: (resp: paginatedDTO) => void) => {
this._reload(callback);
}
loadMore = (loadAll: boolean = false, callback?: () => void) => {
this._loadMore(
loadAll,
callback
);
};
get ascending(): boolean { return this._ascending; }
get ascending(): boolean {
return this._ascending;
}
set ascending(v: boolean) {
this._ascending = v;
// Setting default sort makes sense, since this is the only sort ever being done.
this._c.defaultSortAscending = this.ascending;
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
// NOTE: We don't actually re-sort the list here, instead just use setOrdering to apply this.ascending before a reload.
this._search.setOrdering(this._search.ordering, this._c.defaultSortField, this.ascending);
if (this._hasLoaded) {
this.reload();
if (this._search.inServerSearch) {
// Re-run server search as new, since we changed the sort.
this._search.searchServer(true);
} else {
this.reload();
}
}
}
detectScroll = () => {
if (!this._hasLoaded) return;
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
// window.notifications.customSuccess("scroll", "Reached bottom.");
// Wait .5s between loads
if (this._lastLoad + 500 > Date.now()) return;
this.loadMore();
}
}
private _prevResultCount = 0;
private _notFoundCallback = (notFound: boolean) => {
/*private _notFoundCallback = (notFound: boolean) => {
if (notFound) this._loadMoreButton.classList.add("unfocused");
else this._loadMoreButton.classList.remove("unfocused");
@@ -693,53 +603,6 @@ export class activityList {
this._keepSearchingButton.classList.add("unfocused");
this._keepSearchingDescription.classList.add("unfocused");
}
};
};*/
constructor() {
this._activityList = document.getElementById("activity-card-list");
document.addEventListener("activity-reload", this.reload);
let conf: SearchConfiguration = {
filterArea: this._filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._searchOptionsHeader,
notFoundPanel: this._notFoundPanel,
search: this._searchBox,
clearSearchButtonSelector: ".activity-search-clear",
queries: this._queries,
setVisibility: this.setVisibility,
filterList: document.getElementById("activity-filter-list"),
// notFoundCallback: this._notFoundCallback,
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
this.shown = visibleCount;
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
else this._loadAllButton.classList.add("unfocused");
if (visibleCount < 10 || loadAll) {
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
}
this._prevResultCount = visibleCount;
}
}
this._search = new Search(conf);
this._search.generateFilterList();
this._hasLoaded = false;
this.ascending = false;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
this._loadMoreButton.onclick = () => this.loadMore();
this._loadAllButton.onclick = () => {
addLoader(this._loadAllButton, true);
this.loadMore(() => {}, true);
};
/* this._keepSearchingButton.onclick = () => {
addLoader(this._keepSearchingButton, true);
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
this._refreshButton.onclick = this.reload;
window.onscroll = this.detectScroll;
}
}
+37
View File
@@ -1,4 +1,5 @@
declare var window: GlobalWindow;
import dateParser from "any-date-parser";
export function toDateString(date: Date): string {
const locale = window.language || (window as any).navigator.userLanguage || window.navigator.language;
@@ -21,6 +22,25 @@ export function toDateString(date: Date): string {
return date.toLocaleDateString(locale, args1) + " " + date.toLocaleString(locale, args2);
}
export const parseDateString = (value: string): ParsedDate => {
let out: ParsedDate = {
text: value,
// Used just to tell use what fields the user passed.
attempt: dateParser.attempt(value),
// note Date.fromString is also provided by dateParser.
date: (Date as any).fromString(value) as Date
};
if (("invalid" in (out.date as any))) {
out.invalid = true;
} else {
// getTimezoneOffset returns UTC - Timezone, so invert it to get distance from UTC -to- timezone.
out.attempt.offsetMinutesFromUTC = -1 * out.date.getTimezoneOffset();
}
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in out.attempt) out.attempt.month -= 1;
return out;
}
export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => {
let req = new XMLHttpRequest();
if (window.pages) { url = window.pages.Base + url; }
@@ -306,3 +326,20 @@ export function unicodeB64Encode(s: string): string {
const bin = String.fromCodePoint(...encoded);
return btoa(bin);
}
// Only allow running a function every n milliseconds.
// Source: Clément Prévost at https://stackoverflow.com/questions/27078285/simple-throttle-in-javascript
// function foo<T>(bar: T): T {
export function throttle (callback: () => void, limitMilliseconds: number): () => void {
var waiting = false; // Initially, we're not waiting
return function () { // We return a throttled function
if (!waiting) { // If we're not waiting
callback.apply(this, arguments); // Execute users function
waiting = true; // Prevent future invocations
setTimeout(function () { // After a period of time
waiting = false; // And allow future invocations
}, limitMilliseconds);
}
}
}
+510
View File
@@ -0,0 +1,510 @@
import { _get, _post, addLoader, removeLoader, throttle } from "./common";
import { Search, SearchConfiguration } from "./search";
import "@af-utils/scrollend-polyfill";
declare var window: GlobalWindow;
export interface ListItem {
asElement: () => HTMLElement;
};
export class RecordCounter {
private _container: HTMLElement;
private _totalRecords: HTMLElement;
private _loadedRecords: HTMLElement;
private _shownRecords: HTMLElement;
private _selectedRecords: HTMLElement;
private _total: number;
private _loaded: number;
private _shown: number;
private _selected: number;
constructor(container: HTMLElement) {
this._container = container;
this._container.innerHTML = `
<span class="records-total"></span>
<span class="records-loaded"></span>
<span class="records-shown"></span>
<span class="records-selected"></span>
`;
this._totalRecords = this._container.getElementsByClassName("records-total")[0] as HTMLElement;
this._loadedRecords = this._container.getElementsByClassName("records-loaded")[0] as HTMLElement;
this._shownRecords = this._container.getElementsByClassName("records-shown")[0] as HTMLElement;
this._selectedRecords = this._container.getElementsByClassName("records-selected")[0] as HTMLElement;
this.total = 0;
this.loaded = 0;
this.shown = 0;
}
reset() {
this.total = 0;
this.loaded = 0;
this.shown = 0;
this.selected = 0;
}
// Sets the total using a PageCountDTO-returning API endpoint.
getTotal(endpoint: string) {
_get(endpoint, null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
this.total = req.response["count"] as number;
});
}
get total(): number { return this._total; }
set total(v: number) {
this._total = v;
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
}
get loaded(): number { return this._loaded; }
set loaded(v: number) {
this._loaded = v;
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
}
get shown(): number { return this._shown; }
set shown(v: number) {
this._shown = v;
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
}
get selected(): number { return this._selected; }
set selected(v: number) {
this._selected = v;
if (v == 0) this._selectedRecords.textContent = ``;
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
}
}
export interface PaginatedListConfig {
loader: HTMLElement;
loadMoreButton: HTMLButtonElement;
loadAllButton: HTMLButtonElement;
refreshButton: HTMLButtonElement;
filterArea: HTMLElement;
searchOptionsHeader: HTMLElement;
searchBox: HTMLInputElement;
recordCounter: HTMLElement;
totalEndpoint: string;
getPageEndpoint: string;
itemsPerPage: number;
maxItemsLoadedForSearch: number;
appendNewItems: (resp: paginatedDTO) => void;
replaceWithNewItems: (resp: paginatedDTO) => void;
defaultSortField: string;
defaultSortAscending: boolean;
pageLoadCallback?: (req: XMLHttpRequest) => void;
}
export abstract class PaginatedList {
protected _c: PaginatedListConfig;
// Container to append items to.
protected _container: HTMLElement;
// List of visible IDs (i.e. those set with setVisibility).
protected _visible: string[];
// Infinite-scroll related data.
// Implementation partially based on this blog post, thank you Miina Lervik:
// https://www.bekk.christmas/post/2021/02/how-to-lazy-render-large-data-tables-to-up-performance
protected _scroll = {
rowHeight: 0,
screenHeight: 0,
// Render this many screen's worth of content below the viewport.
renderNExtraScreensWorth: 3,
rendered: 0,
initialRenderCount: 0,
scrollLoading: false,
// Used to calculate scroll speed, so more pages are loaded when scrolling fast.
lastScrollY: 0,
};
protected _search: Search;
protected _counter: RecordCounter;
protected _hasLoaded: boolean;
protected _lastLoad: number;
protected _page: number = 0;
protected _lastPage: boolean;
get lastPage(): boolean { return this._lastPage };
set lastPage(v: boolean) {
this._lastPage = v;
if (v) {
this._c.loadAllButton.classList.add("unfocused");
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
this._c.loadMoreButton.disabled = true;
} else {
this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
this._c.loadMoreButton.disabled = false;
this._c.loadAllButton.classList.remove("unfocused");
}
this.autoSetServerSearchButtonsDisabled();
}
protected _previousVisibleItemCount = 0;
// Stores a PaginatedReqDTO-implementing thing.
// A standard PaginatedReqDTO will be overridden entirely,
// but a ServerSearchDTO will keep it's fields.
protected _searchParams: PaginatedReqDTO;
defaultParams = (): PaginatedReqDTO => {
return {
limit: 0,
page: 0,
sortByField: "",
ascending: false
};
}
constructor(c: PaginatedListConfig) {
this._c = c;
this._counter = new RecordCounter(this._c.recordCounter);
this._hasLoaded = false;
this._c.loadMoreButton.onclick = () => this.loadMore(false);
this._c.loadAllButton.onclick = () => {
addLoader(this._c.loadAllButton, true);
this.loadMore(true);
};
/* this._keepSearchingButton.onclick = () => {
addLoader(this._keepSearchingButton, true);
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
}; */
// Since this.reload doesn't exist, we need an arrow function to wrap it.
this._c.refreshButton.onclick = () => this.reload();
}
autoSetServerSearchButtonsDisabled = () => {
const serverSearchSortChanged = this._search.inServerSearch && (this._searchParams.sortByField != this._search.sortField || this._searchParams.ascending != this._search.ascending);
if (this._search.inServerSearch) {
if (serverSearchSortChanged) {
this._search.setServerSearchButtonsDisabled(false);
} else {
this._search.setServerSearchButtonsDisabled(this.lastPage);
}
return;
}
if (!this._search.inSearch && this._search.sortField == this._c.defaultSortField && this._search.ascending == this._c.defaultSortAscending) {
this._search.setServerSearchButtonsDisabled(true);
return;
}
this._search.setServerSearchButtonsDisabled(false);
}
initSearch = (searchConfig: SearchConfiguration) => {
const previousCallback = searchConfig.onSearchCallback;
searchConfig.onSearchCallback = (newItems: boolean, loadAll: boolean) => {
// if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
// else this._c.loadAllButton.classList.add("unfocused");
this.autoSetServerSearchButtonsDisabled();
// FIXME: Figure out why this makes sense and make it clearer.
if ((this._visible.length < this._c.itemsPerPage && this._counter.loaded < this._c.maxItemsLoadedForSearch && !this.lastPage) || loadAll) {
if (!newItems ||
this._previousVisibleItemCount != this._visible.length ||
(this._visible.length == 0 && !this.lastPage) ||
loadAll
) {
this.loadMore(loadAll);
}
}
this._previousVisibleItemCount = this._visible.length;
if (previousCallback) previousCallback(newItems, loadAll);
};
const previousServerSearch = searchConfig.searchServer;
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
this._searchParams = params;
if (newSearch) this.reload();
else this.loadMore(false);
if (previousServerSearch) previousServerSearch(params, newSearch);
};
searchConfig.clearServerSearch = () => {
console.trace("Clearing server search");
this._page = 0;
this.reload();
}
searchConfig.setVisibility = this.setVisibility;
this._search = new Search(searchConfig);
this._search.generateFilterList();
this.lastPage = false;
};
// Sets the elements with "name"s in "elements" as visible or not.
// setVisibilityNaive = (elements: string[], visible: boolean) => {
// let timer = this._search.timeSearches ? performance.now() : null;
// if (visible) this._visible = elements;
// else this._visible = this._search.ordering.filter(v => !elements.includes(v));
// const frag = document.createDocumentFragment()
// for (let i = 0; i < this._visible.length; i++) {
// frag.appendChild(this._search.items[this._visible[i]].asElement())
// }
// this._container.replaceChildren(frag);
// if (this._search.timeSearches) {
// const totalTime = performance.now() - timer;
// console.log(`setVisibility took ${totalTime}ms`);
// }
// }
// FIXME: Might have broken _counter.shown!
// Sets the elements with "name"s in "elements" as visible or not.
// appendedItems==true implies "elements" is the previously rendered elements plus some new ones on the end. Knowing this means the page's infinite scroll doesn't have to be reset.
setVisibility = (elements: string[], visible: boolean, appendedItems: boolean = false) => {
let timer = this._search.timeSearches ? performance.now() : null;
if (visible) this._visible = elements;
else this._visible = this._search.ordering.filter(v => !elements.includes(v));
// console.log(elements.length, visible, this._visible.length);
this._counter.shown = this._visible.length;
if (this._visible.length == 0) {
this._container.textContent = ``;
return;
}
if (!appendedItems) {
// Wipe old elements and render 1 new one, so we can take the element height.
this._container.replaceChildren(this._search.items[this._visible[0]].asElement())
}
this._computeScrollInfo();
// Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it.
this._scroll.initialRenderCount = Math.floor(Math.min(
this._visible.length,
Math.max(
((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight)/this._scroll.rowHeight,
this._c.itemsPerPage)
));
let baseIndex = 1;
if (appendedItems) {
baseIndex = this._scroll.rendered;
}
const frag = document.createDocumentFragment()
for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement())
}
this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount);
// appendChild over replaceChildren because there's already elements on the DOM
this._container.appendChild(frag);
if (this._search.timeSearches) {
const totalTime = performance.now() - timer;
console.debug(`setVisibility took ${totalTime}ms`);
}
}
// Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change.
_computeScrollInfo = () => {
if (this._visible.length == 0) return;
this._scroll.screenHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0
);
this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
}
// returns the item index to render up to for the given scroll position.
// might return a value greater than this._visible.length, indicating a need for a page load.
maximumItemsToRender = (scrollY: number): number => {
const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight);
const bottomIdx = Math.floor(bottomScroll / this._scroll.rowHeight);
return bottomIdx;
}
private _load = (
itemLimit: number,
page: number,
appendFunc: (resp: paginatedDTO) => void, // Function to append/put items in storage.
pre?: (resp: paginatedDTO) => void,
post?: (resp: paginatedDTO) => void,
failCallback?: (req: XMLHttpRequest) => void
) => {
this._lastLoad = Date.now();
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = itemLimit;
params.page = page;
if (params.sortByField == "") {
params.sortByField = this._c.defaultSortField;
params.ascending = this._c.defaultSortAscending;
}
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (failCallback) failCallback(req);
return;
}
this._hasLoaded = true;
let resp = req.response as paginatedDTO;
if (pre) pre(resp);
this.lastPage = resp.last_page;
appendFunc(resp);
this._counter.loaded = this._search.ordering.length;
if (post) post(resp);
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
}, true);
}
// Removes all elements, and reloads the first page.
public abstract reload: (callback?: (resp: paginatedDTO) => void) => void;
protected _reload = (callback?: (resp: paginatedDTO) => void) => {
this.lastPage = false;
this._counter.reset();
this._counter.getTotal(this._c.totalEndpoint);
// Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)).
let limit = this._c.itemsPerPage;
if (this._page != 0) {
limit *= this._page+1;
}
this._load(
limit,
0,
this._c.replaceWithNewItems,
(_0: paginatedDTO) => {
// Allow refreshes every 15s
this._c.refreshButton.disabled = true;
setTimeout(() => this._c.refreshButton.disabled = false, 15000);
},
(resp: paginatedDTO) => {
this._search.onSearchBoxChange(true, false, false);
if (this._search.inSearch) {
// this._c.loadAllButton.classList.remove("unfocused");
} else {
this._counter.shown = this._counter.loaded;
this.setVisibility(this._search.ordering, true);
// this._search.showHideNotFoundPanel(false);
}
if (callback) callback(resp);
},
);
}
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
public abstract loadMore: (loadAll?: boolean, callback?: () => void) => void;
protected _loadMore = (loadAll: boolean = false, callback?: (resp: paginatedDTO) => void) => {
this._c.loadMoreButton.disabled = true;
const timeout = setTimeout(() => {
this._c.loadMoreButton.disabled = false;
}, 1000);
this._page += 1;
this._load(
this._c.itemsPerPage,
this._page,
this._c.appendNewItems,
(resp: paginatedDTO) => {
// Check before setting this.lastPage so we have a chance to cancel the timeout.
if (resp.last_page) {
clearTimeout(timeout);
removeLoader(this._c.loadAllButton);
}
},
(resp: paginatedDTO) => {
if (this._search.inSearch || loadAll) {
if (this.lastPage) {
loadAll = false;
}
this._search.onSearchBoxChange(true, true, loadAll);
} else {
// Since results come to us ordered already, we can assume "ordering"
// will be identical to pre-page-load but with extra elements at the end,
// allowing infinite scroll to continue
this.setVisibility(this._search.ordering, true, true);
this._search.setNotFoundPanelVisibility(false);
}
if (callback) callback(resp);
},
);
}
loadNItems = (n: number) => {
const cb = () => {
if (this._counter.loaded > n) return;
this.loadMore(false, cb);
}
cb();
}
// As reloading can disrupt long-scrolling, this function will only do it if you're at the top of the page, essentially.
public reloadIfNotInScroll = () => {
if (this._visible.length == 0 || this.maximumItemsToRender(window.scrollY) < this._scroll.initialRenderCount) {
return this.reload();
}
}
_detectScroll = () => {
if (!this._hasLoaded || this._scroll.scrollLoading || this._visible.length == 0) return;
const scrollY = window.scrollY;
const scrollSpeed = scrollY - this._scroll.lastScrollY;
this._scroll.lastScrollY = scrollY;
// If you've scrolled back up, do nothing
if (scrollSpeed < 0) return;
let endIdx = this.maximumItemsToRender(scrollY);
// Throttling this function means we might not catch up in time if the user scrolls fast,
// so we calculate the scroll speed (in rows/call) from the previous scrollY value.
// This still might not be enough, so hackily we'll just scale it up.
// With onscrollend, this is less necessary, but with both I wasn't able to hit the bottom of the page on my mouse.
const rowsPerScroll = Math.round((scrollSpeed / this._scroll.rowHeight));
// Render extra pages depending on scroll speed
endIdx += rowsPerScroll*2;
const realEndIdx = Math.min(endIdx, this._visible.length);
const frag = document.createDocumentFragment();
for (let i = this._scroll.rendered; i < realEndIdx; i++) {
frag.appendChild(this._search.items[this._visible[i]].asElement());
}
this._scroll.rendered = realEndIdx;
this._container.appendChild(frag);
if (endIdx >= this._visible.length) {
if (this.lastPage || this._lastLoad + 500 > Date.now()) return;
this._scroll.scrollLoading = true;
const cb = () => {
if (this._visible.length < endIdx && !this.lastPage) {
// FIXME: This causes scroll-to-top when in search.
this.loadMore(false, cb);
return;
}
this._scroll.scrollLoading = false;
this._detectScroll();
};
cb();
return;
}
}
detectScroll = throttle(this._detectScroll, 200);
computeScrollInfo = throttle(this._computeScrollInfo, 200);
redrawScroll = this.computeScrollInfo;
// bindPageEvents binds window event handlers for when this list/tab containing it is visible.
bindPageEvents = () => {
window.addEventListener("scroll", this.detectScroll);
// Not available on safari, we include a polyfill though.
window.addEventListener("scrollend", this.detectScroll);
window.addEventListener("resize", this.redrawScroll);
};
unbindPageEvents = () => {
window.removeEventListener("scroll", this.detectScroll);
window.removeEventListener("scrollend", this.detectScroll);
window.removeEventListener("resize", this.redrawScroll);
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ export class Login {
}
}, false, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 404 && tryAgain) {
console.log("trying without URL Base...");
console.warn("logout failed, trying without URL Base...");
logoutFunc(this._endpoint, false);
}
});
+466 -147
View File
@@ -1,7 +1,25 @@
const dateParser = require("any-date-parser");
import { ListItem } from "./list";
import { parseDateString } from "./common";
declare var window: GlobalWindow;
export enum QueryOperator {
Greater = ">",
Lower = "<",
Equal = "="
}
export function QueryOperatorToDateText(op: QueryOperator): string {
switch (op) {
case QueryOperator.Greater:
return window.lang.strings("after");
case QueryOperator.Lower:
return window.lang.strings("before");
default:
return "";
}
}
export interface QueryType {
name: string;
description?: string;
@@ -11,41 +29,271 @@ export interface QueryType {
date: boolean;
dependsOnElement?: string; // Format for querySelector
show?: boolean;
localOnly?: boolean // Indicates can't be performed server-side.
}
export interface SearchConfiguration {
filterArea: HTMLElement;
sortingByButton: HTMLButtonElement;
sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
notFoundLocallyText: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement;
clearSearchButtonSelector: string;
serverSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
onSearchCallback: (newItems: boolean, loadAll: boolean) => void;
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
clearServerSearch: () => void;
loadMore?: () => void;
}
export interface SearchableItem {
export interface ServerSearchReqDTO extends PaginatedReqDTO {
searchTerms: string[];
queries: QueryDTO[];
}
export interface QueryDTO {
class: "bool" | "string" | "date";
// QueryType.getter
field: string;
operator: QueryOperator;
value: boolean | string | DateAttempt;
};
export abstract class Query {
protected _subject: QueryType;
protected _operator: QueryOperator;
protected _card: HTMLElement;
public type: string;
constructor(subject: QueryType | null, operator: QueryOperator) {
this._subject = subject;
this._operator = operator;
if (subject != null) {
this._card = document.createElement("span");
this._card.ariaLabel = window.lang.strings("clickToRemoveFilter");
}
}
set onclick(v: () => void) {
this._card.addEventListener("click", v);
}
asElement(): HTMLElement { return this._card; }
public abstract compare(subjectValue: any): boolean;
asDTO(): QueryDTO | null {
if (this.localOnly) return null;
let out = {} as QueryDTO;
out.field = this._subject.getter;
out.operator = this._operator;
return out;
}
get subject(): QueryType { return this._subject; }
getValueFromItem(item: SearchableItem): any {
return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(item), this.subject.getter).get.call(item);
}
compareItem(item: SearchableItem): boolean {
return this.compare(this.getValueFromItem(item));
}
get localOnly(): boolean { return this._subject.localOnly ? true : false; }
}
export class BoolQuery extends Query {
protected _value: boolean;
constructor(subject: QueryType, value: boolean) {
super(subject, QueryOperator.Equal);
this.type = "bool";
this._value = value;
this._card.classList.add("button", "~" + (this._value ? "positive" : "critical"), "@high", "center");
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}</span>
<i class="text-2xl ri-${this._value? "checkbox" : "close"}-circle-fill"></i>
`;
}
public static paramsFromString(valueString: string): [boolean, boolean] {
let isBool = false;
let boolState = false;
if (valueString == "true" || valueString == "yes" || valueString == "t" || valueString == "y") {
isBool = true;
boolState = true;
} else if (valueString == "false" || valueString == "no" || valueString == "f" || valueString == "n") {
isBool = true;
boolState = false;
}
return [boolState, isBool]
}
get value(): boolean { return this._value; }
// Ripped from old code. Why it's like this, I don't know
public compare(subjectBool: boolean): boolean {
return ((subjectBool && this._value) || (!subjectBool && !this._value))
}
asDTO(): QueryDTO | null {
let out = super.asDTO();
if (out === null) return null;
out.class = "bool";
out.value = this._value;
return out;
}
}
export class StringQuery extends Query {
protected _value: string;
constructor(subject: QueryType, value: string) {
super(subject, QueryOperator.Equal);
this.type = "string";
this._value = value.toLowerCase();
this._card.classList.add("button", "~neutral", "@low", "center");
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}:</span> "${this._value}"
`;
}
get value(): string { return this._value; }
public compare(subjectString: string): boolean {
return subjectString.toLowerCase().includes(this._value);
}
asDTO(): QueryDTO | null {
let out = super.asDTO();
if (out === null) return null;
out.class = "string";
out.value = this._value;
return out;
}
}
const dateGetters: Map<string, () => number> = (() => {
let m = new Map<string, () => number>();
m.set("year", Date.prototype.getFullYear);
m.set("month", Date.prototype.getMonth);
m.set("day", Date.prototype.getDate);
m.set("hour", Date.prototype.getHours);
m.set("minute", Date.prototype.getMinutes);
return m;
})();
const dateSetters: Map<string, (v: number) => void> = (() => {
let m = new Map<string, (v: number) => void>();
m.set("year", Date.prototype.setFullYear);
m.set("month", Date.prototype.setMonth);
m.set("day", Date.prototype.setDate);
m.set("hour", Date.prototype.setHours);
m.set("minute", Date.prototype.setMinutes);
return m;
})();
export class DateQuery extends Query {
protected _value: ParsedDate;
constructor(subject: QueryType, operator: QueryOperator, value: ParsedDate) {
super(subject, operator);
this.type = "date";
this._value = value;
this._card.classList.add("button", "~neutral", "@low", "center");
let dateText = QueryOperatorToDateText(operator);
this._card.innerHTML = `
<span class="font-bold mr-2">${subject.name}:</span> ${dateText != "" ? dateText+" " : ""}${value.text}
`;
}
public static paramsFromString(valueString: string): [ParsedDate, QueryOperator, boolean] {
let op = QueryOperator.Equal;
if ((Object.values(QueryOperator) as string[]).includes(valueString.charAt(0))) {
op = valueString.charAt(0) as QueryOperator;
// Trim the operator from the string
valueString = valueString.substring(1);
}
let out = parseDateString(valueString);
let isValid = true;
if (out.invalid) isValid = false;
return [out, op, isValid];
}
get value(): ParsedDate { return this._value; }
public compare(subjectDate: Date): boolean {
// We want to compare only the fields given in this._value,
// so we copy subjectDate and apply on those fields from this._value.
const temp = new Date(subjectDate.valueOf());
for (let [field] of dateGetters) {
if (field in this._value.attempt) {
dateSetters.get(field).call(
temp,
dateGetters.get(field).call(this._value.date)
);
}
}
if (this._operator == QueryOperator.Equal) {
return subjectDate.getTime() == temp.getTime();
} else if (this._operator == QueryOperator.Lower) {
return subjectDate < temp;
}
return subjectDate > temp;
}
asDTO(): QueryDTO | null {
let out = super.asDTO();
if (out === null) return null;
out.class = "date";
out.value = this._value.attempt;
return out;
}
}
export interface SearchableItem extends ListItem {
matchesSearch: (query: string) => boolean;
}
export const SearchableItemDataAttribute = "data-search-item";
export type SearchableItems = { [id: string]: SearchableItem };
export class Search {
private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
private _ordering: string[] = [];
private _items: { [id: string]: SearchableItem };
inSearch: boolean;
private _items: SearchableItems = {};
// Search queries (filters)
private _queries: Query[] = [];
// Plain-text search terms
private _searchTerms: string[] = [];
inSearch: boolean = false;
private _inServerSearch: boolean = false;
get inServerSearch(): boolean { return this._inServerSearch; }
set inServerSearch(v: boolean) {
const previous = this._inServerSearch;
this._inServerSearch = v;
if (!v && previous != v) {
this._c.clearServerSearch();
}
}
search = (query: String): string[] => {
this._c.filterArea.textContent = "";
// Intended to be set from the JS console, if true searches are timed.
timeSearches: boolean = false;
private _serverSearchButtons: HTMLElement[];
static tokenizeSearch = (query: string): string[] => {
query = query.toLowerCase();
let result: string[] = [...this._ordering];
let words: string[] = [];
let quoteSymbol = ``;
let queryStart = -1;
let lastQuote = -1;
@@ -75,174 +323,144 @@ export class Search {
}
}
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1;
}
}
}
return words;
}
query = "";
for (let word of words) {
parseTokens = (tokens: string[]): [string[], Query[]] => {
let queries: Query[] = [];
let searchTerms: string[] = [];
for (let word of tokens) {
// 1. Normal search text, no filters or anything
if (!word.includes(":")) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
searchTerms.push(word);
continue;
}
// 2. A filter query of some sort.
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue;
const queryFormat = this._c.queries[split[0]];
if (queryFormat.bool) {
let isBool = false;
let boolState = false;
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
isBool = true;
boolState = true;
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
isBool = true;
boolState = false;
}
if (isBool) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}</span>
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
`;
let q: Query | null = null;
filterCard.addEventListener("click", () => {
if (queryFormat.bool) {
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
if (isBool) {
q = new BoolQuery(queryFormat, boolState);
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
// console.log("is bool, state", boolState);
// So removing elements doesn't affect us
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
// console.log("got", queryFormat.getter + ":", value);
// Remove from result if not matching query
if (!((value && boolState) || (!value && !boolState))) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
}
}
continue
};
queries.push(q);
continue;
}
}
if (queryFormat.string) {
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
`;
q = new StringQuery(queryFormat, split[1]);
filterCard.addEventListener("click", () => {
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
if (!(value.includes(split[1]))) {
result.splice(result.indexOf(id), 1);
}
}
queries.push(q);
continue;
}
if (queryFormat.date) {
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
let unmodifiedValue = split[1];
if (compareType != 2) {
split[1] = split[1].substring(1);
}
if (compareType == 2) compareType = 0;
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
// Month in Date objects is 0-based, so make our parsed date that way too
if ("month" in attempt) attempt.month -= 1;
let date: Date = (Date as any).fromString(split[1]) as Date;
console.log("Read", attempt, "and", date);
if ("invalid" in (date as any)) continue;
const filterCard = document.createElement("span");
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
filterCard.innerHTML = `
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
`;
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue;
q = new DateQuery(queryFormat, op, parsedDate);
filterCard.addEventListener("click", () => {
q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
})
this._c.filterArea.appendChild(filterCard);
}
queries.push(q);
continue;
}
// if (q != null) queries.push(q);
}
return [searchTerms, queries];
}
// Returns a list of identifiers (used as keys in items, values in ordering).
searchParsed = (searchTerms: string[], queries: Query[]): string[] => {
let result: string[] = [...this._ordering];
// If we didn't care about rendering the query cards, we could run this to (maybe) return early.
// if (this.inServerSearch) {
// let hasLocalOnlyQueries = false;
// for (const q of queries) {
// if (q.localOnly) {
// hasLocalOnlyQueries = true;
// break;
// }
// }
// }
// Normal searches can be evaluated by the server, so skip this if we've already ran one.
if (!this.inServerSearch) {
for (let term of searchTerms) {
let cachedResult = [...result];
for (let id of cachedResult) {
const u = this._items[id];
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
const u = this.items[id];
if (!u.matchesSearch(term)) {
result.splice(result.indexOf(id), 1);
}
}
}
}
for (let q of queries) {
this._c.filterArea.appendChild(q.asElement());
// Skip if this query has already been performed by the server.
if (this.inServerSearch && !(q.localOnly)) continue;
let cachedResult = [...result];
if (q.type == "bool") {
for (let id of cachedResult) {
const u = this.items[id];
// Remove from result if not matching query
if (!q.compareItem(u)) {
// console.log("not matching, result is", result);
result.splice(result.indexOf(id), 1);
}
}
} else if (q.type == "string") {
for (let id of cachedResult) {
const u = this.items[id];
// We want to compare case-insensitively, so we get value, lower-case it then compare,
// rather than doing both with compareItem.
const value = q.getValueFromItem(u).toLowerCase();
if (!q.compare(value)) {
result.splice(result.indexOf(id), 1);
}
}
} else if (q.type == "date") {
for (let id of cachedResult) {
const u = this.items[id];
// Getter here returns a unix timestamp rather than a date, so we can't use compareItem.
const unixValue = q.getValueFromItem(u);
if (unixValue == 0) {
result.splice(result.indexOf(id), 1);
continue;
}
let value = new Date(unixValue*1000);
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
const temp = new Date(date.valueOf());
temp.setHours(value.getHours(), value.getMinutes());
value = temp;
console.log("just hours/minutes workaround, value set to", value);
}
let match = true;
if (compareType == 0) {
for (let pair of getterPairs) {
if (pair[0] in attempt) {
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
match = false;
break;
}
}
}
} else if (compareType == -1) {
match = (value < date);
} else if (compareType == 1) {
match = (value > date);
}
if (!match) {
if (!q.compare(value)) {
result.splice(result.indexOf(id), 1);
}
}
@@ -250,11 +468,35 @@ export class Search {
}
return result;
}
// Returns a list of identifiers (used as keys in items, values in ordering).
search = (query: string): string[] => {
let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
let result = this.searchParsed(searchTerms, queries);
this._queries = queries;
this._searchTerms = searchTerms;
if (this.timeSearches) {
const totalTime = performance.now() - timer;
console.debug(`Search took ${totalTime}ms`);
}
return result;
}
// postServerSearch performs local-only queries after a server search if necessary.
postServerSearch = () => {
this.searchParsed(this._searchTerms, this._queries);
};
showHideSearchOptionsHeader = () => {
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
let sortingBy = false;
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
} else {
@@ -262,16 +504,26 @@ export class Search {
}
}
// -all- elements.
get items(): { [id: string]: SearchableItem } { return this._items; }
set items(v: { [id: string]: SearchableItem }) {
this._items = v;
// set items(v: { [id: string]: SearchableItem }) {
// this._items = v;
// }
// The order of -all- elements (even those hidden), by their identifier.
get ordering(): string[] { return this._ordering; }
// Specifically dis-allow setting ordering itself, so that setOrdering is used instead (for the field and ascending params).
// set ordering(v: string[]) { this._ordering = v; }
setOrdering = (v: string[], field: string, ascending: boolean) => {
this._ordering = v;
this._sortField = field;
this._ascending = ascending;
}
get ordering(): string[] { return this._ordering; }
set ordering(v: string[]) { this._ordering = v; }
get sortField(): string { return this._sortField; }
get ascending(): boolean { return this._ascending; }
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
onSearchBoxChange = (newItems: boolean = false, appendedItems: boolean = false, loadAll: boolean = false) => {
const query = this._c.search.value;
if (!query) {
this.inSearch = false;
@@ -279,15 +531,37 @@ export class Search {
this.inSearch = true;
}
const results = this.search(query);
this._c.setVisibility(results, true);
this._c.onSearchCallback(results.length, newItems, loadAll);
this._c.setVisibility(results, true, appendedItems);
this._c.onSearchCallback(newItems, loadAll);
if (this.inSearch) {
if (this.inServerSearch) {
this._serverSearchButtons.forEach((v: HTMLElement) => {
v.classList.add("@low");
v.classList.remove("@high");
});
} else {
this._serverSearchButtons.forEach((v: HTMLElement) => {
v.classList.add("@high");
v.classList.remove("@low");
});
}
}
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this.setNotFoundPanelVisibility(results.length == 0);
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
}
setNotFoundPanelVisibility = (visible: boolean) => {
if (this._inServerSearch || !this.inSearch) {
this._c.notFoundLocallyText.classList.add("unfocused");
} else if (this.inSearch) {
this._c.notFoundLocallyText.classList.remove("unfocused");
}
if (visible) {
this._c.notFoundPanel.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
}
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
}
fillInFilter = (name: string, value: string, offset?: number) => {
@@ -299,8 +573,6 @@ export class Search {
this._c.search.setSelectionRange(newPos, newPos);
this._c.search.oninput(null as any);
};
generateFilterList = () => {
// Generate filter buttons
@@ -376,17 +648,64 @@ export class Search {
}
}
onServerSearch = () => {
const newServerSearch = !this.inServerSearch;
this.inServerSearch = true;
this.searchServer(newServerSearch);
}
searchServer = (newServerSearch: boolean) => {
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
}
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
let req: ServerSearchReqDTO = {
searchTerms: searchTerms,
queries: [], // queries.map((q: Query) => q.asDTO()) won't work as localOnly queries return null
limit: -1,
page: 0,
sortByField: this.sortField,
ascending: this.ascending
};
for (const q of queries) {
const dto = q.asDTO();
if (dto !== null) req.queries.push(dto);
}
return req;
}
setServerSearchButtonsDisabled = (disabled: boolean) => {
this._serverSearchButtons.forEach((v: HTMLButtonElement) => v.disabled = disabled);
}
constructor(c: SearchConfiguration) {
this._c = c;
this._c.search.oninput = () => this.onSearchBoxChange();
this._c.search.oninput = () => {
this.inServerSearch = false;
this.onSearchBoxChange();
}
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
if (ev.key == "Enter") {
this.onServerSearch();
}
});
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) {
b.addEventListener("click", () => {
this._c.search.value = "";
this.inServerSearch = false;
this.onSearchBoxChange();
});
}
this._serverSearchButtons = Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of this._serverSearchButtons) {
b.addEventListener("click", () => {
this.onServerSearch();
});
}
}
}
+2 -3
View File
@@ -1,7 +1,5 @@
import { PageManager, Page } from "../modules/pages.js";
declare var window: GlobalWindow;
export interface Tab {
page: Page;
tabEl: HTMLDivElement;
@@ -26,7 +24,7 @@ export class Tabs implements Tabs {
});
}
addTab = (tabID: string, url: string, preFunc = () => void {}, postFunc = () => void {},) => {
addTab = (tabID: string, url: string, preFunc = () => void {}, postFunc = () => void {}, unloadFunc = () => void {}) => {
let tab: Tab = {
page: null,
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
@@ -52,6 +50,7 @@ export class Tabs implements Tabs {
tab.buttonEl.classList.remove("active");
tab.buttonEl.classList.remove("~urge");
tab.tabEl.classList.add("unfocused");
if (unloadFunc) unloadFunc();
return true;
},
shouldSkip: () => false,
+27 -5
View File
@@ -91,7 +91,7 @@ declare interface NotificationBox {
declare interface Tabs {
current: string;
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void) => void;
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void, unloadFunc?: () => void) => void;
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
}
@@ -150,10 +150,32 @@ interface inviteList {
loadInviteURL: () => void;
}
// Finally added to typescript, dont need this anymore.
// declare interface SubmitEvent extends Event {
// submitter: HTMLInputElement;
// }
interface paginatedDTO {
last_page: boolean;
}
interface PaginatedReqDTO {
limit: number;
page: number;
sortByField: string;
ascending: boolean;
};
interface DateAttempt {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
offsetMinutesFromUTC?: number;
}
interface ParsedDate {
attempt: DateAttempt;
date: Date;
text: string;
invalid?: boolean;
};
declare var config: Object;
declare var modifiedConfig: Object;
+1 -1
View File
@@ -749,7 +749,7 @@ const setCardOrder = (messageCard: HTMLElement) => {
// addValue += side.length;
}
console.log("Shortest order:", minHeightPerm);
console.debug("Shortest order:", minHeightPerm);
};
const login = new Login(window.modals.login as Modal, "/my/", "opaque");
+533
View File
@@ -0,0 +1,533 @@
package main
import (
"cmp"
"encoding/json"
"fmt"
"slices"
"strings"
"sync"
"time"
)
const (
USER_DEFAULT_SORT_FIELD = "name"
USER_DEFAULT_SORT_ASCENDING = true
)
// 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
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)
}
}
+6 -1
View File
@@ -67,9 +67,10 @@ func (app *appContext) pushResources(gc *gin.Context, page Page) {
default:
toPush = []string{}
}
urlBase := app.getURLBase(gc)
if pusher := gc.Writer.Pusher(); pusher != nil {
for _, f := range toPush {
if err := pusher.Push(PAGES.Base+f, nil); err != nil {
if err := pusher.Push(urlBase+f, nil); err != nil {
app.debug.Printf(lm.FailedServerPush, err)
}
}
@@ -172,6 +173,10 @@ func (app *appContext) getLang(gc *gin.Context, page Page, chosen string) string
func (app *appContext) AdminPage(gc *gin.Context) {
app.pushResources(gc, AdminPage)
// Pre-emptively (maybe) generate user cache
go app.userCache.MaybeSync(app)
lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang)
jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false)