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" "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 { func activityTypeToString(v ActivityType) string {
switch v { switch v {
case ActivityCreation: case ActivityCreation:
@@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string {
return "unknown" 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 { func stringToActivitySource(v string) ActivitySource {
switch v { switch v {
case "user": 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. // @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 // @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" // @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO // @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post] // @Router /activity [post]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) { func (app *appContext) GetActivities(gc *gin.Context) {
req := GetActivitiesDTO{} req := ServerSearchReqDTO{}
gc.BindJSON(&req) gc.BindJSON(&req)
query := &badgerhold.Query{} if req.SortByField == "" {
activityTypes := make([]interface{}, len(req.Type)) req.SortByField = USER_DEFAULT_SORT_FIELD
for i, v := range req.Type { } else {
activityTypes[i] = stringToActivityType(v) req.SortByField = activityDTONameToField(req.SortByField)
}
if len(activityTypes) != 0 {
query = badgerhold.Where("Type").In(activityTypes...)
} }
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 { if !req.Ascending {
query = query.Reverse() query = query.Reverse()
} }
query = query.SortBy("Time")
if req.Limit == 0 {
req.Limit = 10
}
query = query.Skip(req.Page * req.Limit).Limit(req.Limit) query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
var results []Activity var results []Activity
err := app.storage.db.Find(&results, query) err := app.storage.db.Find(&results, query)
if err != nil { if err != nil {
app.err.Printf(lm.FailedDBReadActivities, err) app.err.Printf(lm.FailedDBReadActivities, err)
} }
resp := GetActivitiesRespDTO{ resp := GetActivitiesRespDTO{
Activities: make([]ActivityDTO, len(results)), Activities: make([]ActivityDTO, len(results)),
LastPage: len(results) != req.Limit,
} }
resp.LastPage = len(results) != req.Limit
for i, act := range results { for i, act := range results {
resp.Activities[i] = ActivityDTO{ resp.Activities[i] = ActivityDTO{
ID: act.ID, ID: act.ID,
Type: activityTypeToString(act.Type), Type: activityTypeToString(act.Type),
UserID: act.UserID, UserID: act.UserID,
SourceType: activitySourceToString(act.SourceType), SourceType: activitySourceToString(act.SourceType),
Source: act.Source, Source: act.Source,
InviteCode: act.InviteCode, InviteCode: act.InviteCode,
Value: act.Value, Value: act.Value,
Time: act.Time.Unix(), Time: act.Time.Unix(),
IP: act.IP, IP: act.IP,
Username: act.MustGetUsername(app.jf),
SourceUsername: act.MustGetSourceUsername(app.jf),
} }
if act.Type == ActivityDeletion || act.Type == ActivityCreation { 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 = "" 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. // @Summary Returns the total number of activities stored in the database.
// @Produce json // @Produce json
// @Success 200 {object} GetActivityCountDTO // @Success 200 {object} PageCountDTO
// @Router /activity/count [get] // @Router /activity/count [get]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity
func (app *appContext) GetActivityCount(gc *gin.Context) { func (app *appContext) GetActivityCount(gc *gin.Context) {
resp := GetActivityCountDTO{} resp := PageCountDTO{}
var err error var err error
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{}) resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
if err != nil { if err != nil {
+71 -10
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"slices"
"strings" "strings"
"time" "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, // FIXME: figure these out in a nicer way? this relies on the current ordering,
// which may not be fixed. // which may not be fixed.
if discordEnabled { if discordEnabled {
if req.completeContactMethods[0].User != nil { if req.completeContactMethods[0].User != nil {
discordUser = req.completeContactMethods[0].User.(*DiscordUser) discordUser = req.completeContactMethods[0].User.(*DiscordUser)
} }
if telegramEnabled && req.completeContactMethods[1].User != nil { if telegramEnabled && req.completeContactMethods[1].User != nil {
telegramUser = req.completeContactMethods[1].User.(*TelegramUser) 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 // @Produce json
// @Success 200 {object} getUsersDTO // @Success 200 {object} getUsersDTO
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
@@ -903,19 +922,61 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
// @tags Users // @tags Users
func (app *appContext) GetUsers(gc *gin.Context) { func (app *appContext) GetUsers(gc *gin.Context) {
var resp getUsersDTO var resp getUsersDTO
users, err := app.jf.GetUsers(false) // We're sending all users, so this is always true
resp.UserList = make([]respUser, len(users)) resp.LastPage = true
var err error
resp.UserList, err = app.userCache.GetUserDTOs(app, true)
if err != nil { if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc) respond(500, "Couldn't get users", gc)
return return
} }
i := 0 gc.JSON(200, resp)
for _, jfUser := range users { }
user := app.userSummary(jfUser)
resp.UserList[i] = user // @Summary Get a paginated, searchable list of Jellyfin users.
i++ // @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) 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").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) 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) LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false) LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
+14
View File
@@ -65,6 +65,20 @@ sections:
type: number type: number
value: 30 value: 30
description: Timeout of user cache in minutes. Set to 0 to disable. 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 - setting: type
name: Server type name: Server type
requires_restart: true 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; right: 0;
} }
.tooltip.above .content {
bottom: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content { .tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8); 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/logger v0.0.0-20241105225412-da4470bc4fbc
github.com/hrfee/jfa-go/logmessages 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/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/itchyny/timefmt-go v0.1.6
github.com/lithammer/shortuuid/v3 v3.0.7 github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mailgun/mailgun-go/v4 v4.18.1 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/flatbuffers v24.3.25+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // 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/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/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.27 h1:8bxPamBFLD1Xqy6pf6M3Oc5GUQ0iU/flO0S64G1AsIM=
github.com/hrfee/mediabrowser v0.3.25/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= 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/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 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
+72 -45
View File
@@ -715,16 +715,24 @@
</div> </div>
</div> </div>
<div id="tab-accounts" class="flex flex-col gap-4 unfocused"> <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 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-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal"> <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="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> <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>
<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 }}"> <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> </div>
<div class="dropdown-display max-w-full"> <div class="dropdown-display max-w-full">
@@ -733,13 +741,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div> <div class="flex flex-row justify-between">
<div class="row -mx-2 mb-2"> <div class="supra sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<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> <div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
<span id="accounts-filter-area"></span>
</div> </div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div> <div class="flex flex-row gap-2 flex-wrap">
<div class="flex flex-row flex-wrap gap-3 mb-4"> <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> <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"> <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> <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 ~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> <span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div> </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"> <table class="table text-base leading-5">
<thead> <thead>
<tr> <tr>
@@ -802,19 +813,31 @@
</thead> </thead>
<tbody id="accounts-list"></tbody> <tbody id="accounts-list"></tbody>
</table> </table>
<div id="accounts-loader"></div>
<div class="unfocused h-[100%] my-3" id="accounts-not-found"> <div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col h-[100%] justify-center items-center"> <div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<button class="button ~neutral @low accounts-search-clear"> <span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i> <div class="flex flex-row">
</button> <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> </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>
</div> </div>
<div id="tab-activity" class="flex flex-col gap-4 unfocused"> <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 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-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal"> <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> <button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div> </div>
</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 }}"> <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> <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>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button> <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> </div>
<div class="dropdown-display max-w-full"> <div class="dropdown-display max-w-full">
@@ -836,37 +866,34 @@
</div> </div>
</div> </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 hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2"> <div class="supra sm flex flex-row gap-2" id="activity-record-counter"></div>
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
</div> </div>
<div class="row -mx-2 mb-2"> <div class="flex flex-row gap-2 flex-wrap">
<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" class="flex flex-row gap-2 flex-wrap"></span>
<span id="activity-filter-area"></span>
</div> </div>
<div class="my-2"> <div class="unfocused h-[100%]" id="activity-not-found">
<div id="activity-card-list"></div> <div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<div id="activity-loader"></div> <span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<div class="unfocused h-[100%] my-3" id="activity-not-found"> <span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-col h-[100%] justify-center items-center"> <div class="flex flex-row">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span> <button class="button ~neutral @low activity-search-clear flex flex-row gap-2">
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span> <span>{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
<div class="flex flex-row"> </button>
<button class="button ~neutral @low activity-search-clear"> <button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
<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> </div>
</div> </div>
<div class="flex justify-center"> </div>
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button> <div id="activity-card-list"></div>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button> <div id="activity-loader"></div>
</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> </div>
</div> </div>
+4
View File
@@ -58,6 +58,7 @@
"disabled": "Disabled", "disabled": "Disabled",
"sendPWR": "Send Password Reset", "sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found", "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", "keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.", "keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:", "contactThrough": "Contact through:",
@@ -136,6 +137,8 @@
"filters": "Filters", "filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.", "clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search", "clearSearch": "Clear search",
"searchAll": "Search/sort all",
"searchAllRecords": "Search/sort all records (on server)",
"actions": "Actions", "actions": "Actions",
"searchOptions": "Search Options", "searchOptions": "Search Options",
"matchText": "Match Text", "matchText": "Match Text",
@@ -190,6 +193,7 @@
"totalRecords": "{n} Total Records", "totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded", "loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown", "shownRecords": "{n} Shown",
"selectedRecords": "{n} Selected",
"backups": "Backups", "backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.", "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.", "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. // Regex that removes ANSI color escape sequences. Used for outputting to log file and log cache.
var stripColors = func() *regexp.Regexp { var stripColors = func() *regexp.Regexp {
r, err := regexp.Compile("\\x1b\\[[0-9;]*m") r, err := regexp.Compile(`\x1b\[[0-9;]*m`)
if err != nil { if err != nil {
log.Fatalf("Failed to compile color escape regexp: %v", err) 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\"" SetAdminNotify = "Set \"%s\" to %t for admin address \"%s\""
// *jellyseerr*.go // *jellyseerr*.go
FailedGetUsers = "Failed to get user(s) from %s: %v" 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.
FailedGetUser = "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" FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v"
FailedSyncContactMethods = "Failed to sync contact methods with %s: %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 pwrCaptchas map[string]Captcha
ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex confirmationKeysLock sync.Mutex
userCache *UserCache
} }
func generateSecret(length int) (string, error) { func generateSecret(length int) (string, error) {
@@ -405,7 +406,7 @@ func start(asDaemon, firstCall bool) {
// Initialize jellyfin/emby connection // Initialize jellyfin/emby connection
server := app.config.Section("jellyfin").Key("server").String() 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() stringServerType := app.config.Section("jellyfin").Key("type").String()
timeoutHandler := mediabrowser.NewNamedTimeoutHandler("Jellyfin", "\""+server+"\"", true) timeoutHandler := mediabrowser.NewNamedTimeoutHandler("Jellyfin", "\""+server+"\"", true)
if stringServerType == "emby" { 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. // 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. // Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport.
app.email = NewEmailer(app) 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. // NOTE: The order in which these are placed in app.contactMethods matters.
// Add new ones to the end. // Add new ones to the end.
// FIXME: Add proxies. // Proxies are added a little later through ContactMethodLinker[].SetTransport.
if discordEnabled { if discordEnabled {
app.discord, err = newDiscordDaemon(app) app.discord, err = newDiscordDaemon(app)
if err != nil { if err != nil {
+15 -10
View File
@@ -164,8 +164,20 @@ type respUser struct {
ReferralsEnabled bool `json:"referrals_enabled"` 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 { type getUsersDTO struct {
UserList []respUser `json:"users"` UserList []*respUser `json:"users"`
LastPage bool `json:"last_page"`
} }
type ombiUser struct { type ombiUser struct {
@@ -429,19 +441,12 @@ type ActivityDTO struct {
IP string `json:"ip"` 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 { type GetActivitiesRespDTO struct {
PaginatedDTO
Activities []ActivityDTO `json:"activities"` Activities []ActivityDTO `json:"activities"`
LastPage bool `json:"last_page"`
} }
type GetActivityCountDTO struct { type PageCountDTO struct {
Count uint64 `json:"count"` Count uint64 `json:"count"`
} }
+12
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@ts-stack/markdown": "^1.4.0", "@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"a17t": "^0.10.1", "a17t": "^0.10.1",
@@ -36,6 +37,12 @@
"esbuild": "^0.18.20" "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": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -7293,6 +7300,11 @@
} }
}, },
"dependencies": { "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": { "@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "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", "homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@ts-stack/markdown": "^1.4.0", "@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"a17t": "^0.10.1", "a17t": "^0.10.1",
+2
View File
@@ -183,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/logout", app.Logout) router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers) api.DELETE(p+"/users", app.DeleteUsers)
api.GET(p+"/users", app.GetUsers) 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+"/user", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry) api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry) api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
+17 -5
View File
@@ -14,12 +14,13 @@ import (
) )
var ( 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 ( const (
PASSWORD = "test" PASSWORD = "test"
COUNT = 10
) )
func main() { func main() {
@@ -57,6 +58,12 @@ func main() {
password = strings.TrimSuffix(password, "\n") 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( jf, err := mediabrowser.NewServer(
mediabrowser.JellyfinServer, mediabrowser.JellyfinServer,
server, server,
@@ -95,11 +102,12 @@ func main() {
rand.Seed(time.Now().Unix()) rand.Seed(time.Now().Unix())
for i := 0; i < COUNT; i++ { 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) user, status, err := jf.NewUser(name, PASSWORD)
if (status != 200 && status != 201 && status != 204) || err != nil { 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 { if rand.Intn(100) > 65 {
@@ -110,13 +118,17 @@ func main() {
user.Policy.IsDisabled = true user.Policy.IsDisabled = true
} }
time.Sleep(DELAY / 4)
status, err = jf.SetPolicy(user.ID, user.Policy) status, err = jf.SetPolicy(user.ID, user.Policy)
if (status != 200 && status != 201 && status != 204) || err != nil { 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 { if rand.Intn(100) > 20 {
time.Sleep(DELAY / 4)
jfTemp.Authenticate(name, PASSWORD) 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(); let isAccountURL = accounts.isAccountURL();
// load tabs // load tabs
const tabs: { id: string, url: string, reloader: () => void }[] = [ const tabs: { id: string, url: string, reloader: () => void, unloader?: () => void }[] = [
{ {
id: "invites", id: "invites",
url: "", url: "",
@@ -148,12 +148,19 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [
// Don't keep loading the same item on every tab refresh // Don't keep loading the same item on every tab refresh
isAccountURL = false; isAccountURL = false;
} }
accounts.bindPageEvents();
}), }),
unloader: accounts.unbindPageEvents
}, },
{ {
id: "activity", id: "activity",
url: "activity", url: "activity",
reloader: activity.reload reloader: () => {
activity.reload()
activity.bindPageEvents();
},
unloader: activity.unbindPageEvents
}, },
{ {
id: "settings", id: "settings",
@@ -167,7 +174,7 @@ const defaultTab = tabs[0];
window.tabs = new Tabs(); window.tabs = new Tabs();
for (let tab of 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 let matchedTab = false
@@ -188,7 +195,7 @@ login.onLogin = () => {
window.updater = new Updater(); window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not // FIXME: Decide whether to autoload activity or not
reloadProfileNames(); 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 // Triggers pre and post funcs, even though we're already on that page
window.tabs.switch(window.tabs.current); 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 logSanitized = document.getElementById("log-sanitized") as HTMLInputElement;
const buttonChange = (type: string) => { const buttonChange = (type: string) => {
console.log("RUN");
if (type == "normal") { if (type == "normal") {
logSanitized.classList.add("unfocused"); logSanitized.classList.add("unfocused");
logNormal.classList.remove("unfocused"); logNormal.classList.remove("unfocused");
+530 -498
View File
File diff suppressed because it is too large Load Diff
+230 -367
View File
@@ -1,10 +1,14 @@
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js"; import { _get, _post, _delete, toDateString } from "../modules/common.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js"; import { SearchConfiguration, QueryType, SearchableItem, SearchableItems, SearchableItemDataAttribute } from "../modules/search.js";
import { accountURLEvent } from "../modules/accounts.js"; import { accountURLEvent } from "../modules/accounts.js";
import { inviteURLEvent } from "../modules/invites.js"; import { inviteURLEvent } from "../modules/invites.js";
import { PaginatedList } from "./list.js";
declare var window: GlobalWindow; declare var window: GlobalWindow;
const ACTIVITY_DEFAULT_SORT_FIELD = "time";
const ACTIVITY_DEFAULT_SORT_ASCENDING = false;
export interface activity { export interface activity {
id: string; id: string;
type: string; type: string;
@@ -32,6 +36,124 @@ var activityTypeMoods = {
"deleteInvite": -1 "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"]; // var moodColours = ["~warning", "~neutral", "~urge"];
export var activityReload = new CustomEvent("activity-reload"); export var activityReload = new CustomEvent("activity-reload");
@@ -232,7 +354,10 @@ export class Activity implements activity, SearchableItem {
} }
get id(): string { return this._act.id; } 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; } get user_id(): string { return this._act.user_id; }
set user_id(v: string) { this._act.user_id = v; } 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 = document.createElement("div");
this._card.classList.add("card", "@low", "my-2"); this._card.classList.add("card", "@low", "my-2");
this._card.innerHTML = ` this._card.innerHTML = `
<div class="flex flex-col md:flex-row justify-between mb-2"> <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> <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; }; asElement = () => { return this._card; };
} }
interface ActivitiesDTO { interface ActivitiesReqDTO extends PaginatedReqDTO {
type: string[];
};
interface ActivitiesDTO extends paginatedDTO {
activities: activity[]; activities: activity[];
last_page: boolean;
} }
export class activityList { export class activityList extends PaginatedList {
private _activityList: HTMLElement; protected _container: HTMLElement;
private _activities: { [id: string]: Activity } = {}; protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
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");
private _totalRecords = document.getElementById("activity-total-records"); protected _ascending: boolean;
private _loadedRecords = document.getElementById("activity-loaded-records");
private _shownRecords = document.getElementById("activity-shown-records");
private _total: number; get activities(): { [id: string]: Activity } { return this._search.items as { [id: string]: Activity }; }
private _loaded: number; // set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
private _shown: number;
get total(): number { return this._total; } constructor() {
set total(v: number) { super({
this._total = v; loader: document.getElementById("activity-loader"),
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`); 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.
get loaded(): number { return this._loaded; } // Remove existing items
set loaded(v: number) { for (let id of Object.keys(this.activities)) {
this._loaded = v; delete this.activities[id];
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`); }
} // And wipe their ordering
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
get shown(): number { return this._shown; } this._c.appendNewItems(resp);
set shown(v: number) { },
this._shown = v; defaultSortField: ACTIVITY_DEFAULT_SORT_FIELD,
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`); defaultSortAscending: ACTIVITY_DEFAULT_SORT_ASCENDING,
} pageLoadCallback: (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
private _search: Search; if (req.status != 200) {
private _ascending: boolean; window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
private _hasLoaded: boolean; return;
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) => { this._container = document.getElementById("activity-card-list")
if (req.readyState != 4) return; document.addEventListener("activity-reload", () => this.reload());
if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
return;
}
this._hasLoaded = true; let searchConfig: SearchConfiguration = {
// Allow refreshes every 15s filterArea: this._c.filterArea,
this._refreshButton.disabled = true; // Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
setTimeout(() => this._refreshButton.disabled = false, 15000); // sortingByButton: this._sortingByButton,
searchOptionsHeader: this._c.searchOptionsHeader,
let resp = req.response as ActivitiesDTO; notFoundPanel: document.getElementById("activity-not-found"),
// FIXME: Don't destroy everything each reload! notFoundLocallyText: document.getElementById("activity-no-local-results"),
this._activities = {}; search: this._c.searchBox,
this._ordering = []; clearSearchButtonSelector: ".activity-search-clear",
serverSearchButtonSelector: ".activity-search-server",
for (let act of resp.activities) { queries: queries(),
this._activities[act.id] = new Activity(act); setVisibility: null,
this._ordering.push(act.id); filterList: document.getElementById("activity-filter-list"),
} // notFoundCallback: this._notFoundCallback,
this._search.items = this._activities; onSearchCallback: null,
this._search.ordering = this._ordering; searchServer: null,
clearServerSearch: null,
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;
}
this._search.onSearchBoxChange(true, loadAll);
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused");
}
if (callback) callback();
// removeLoader(this._loader);
// this._activityList.classList.remove("unfocused");
}, true);
}
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
} }
this.initSearch(searchConfig);
this.ascending = this._c.defaultSortAscending;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
}
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) { set ascending(v: boolean) {
this._ascending = v; 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>`; 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) { 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 = () => { /*private _notFoundCallback = (notFound: boolean) => {
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) => {
if (notFound) this._loadMoreButton.classList.add("unfocused"); if (notFound) this._loadMoreButton.classList.add("unfocused");
else this._loadMoreButton.classList.remove("unfocused"); else this._loadMoreButton.classList.remove("unfocused");
@@ -693,53 +603,6 @@ export class activityList {
this._keepSearchingButton.classList.add("unfocused"); this._keepSearchingButton.classList.add("unfocused");
this._keepSearchingDescription.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; declare var window: GlobalWindow;
import dateParser from "any-date-parser";
export function toDateString(date: Date): string { export function toDateString(date: Date): string {
const locale = window.language || (window as any).navigator.userLanguage || window.navigator.language; 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); 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 => { export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => {
let req = new XMLHttpRequest(); let req = new XMLHttpRequest();
if (window.pages) { url = window.pages.Base + url; } if (window.pages) { url = window.pages.Base + url; }
@@ -306,3 +326,20 @@ export function unicodeB64Encode(s: string): string {
const bin = String.fromCodePoint(...encoded); const bin = String.fromCodePoint(...encoded);
return btoa(bin); 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) => { }, false, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 404 && tryAgain) { 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); logoutFunc(this._endpoint, false);
} }
}); });
+465 -146
View File
@@ -1,7 +1,25 @@
const dateParser = require("any-date-parser"); import { ListItem } from "./list";
import { parseDateString } from "./common";
declare var window: GlobalWindow; 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 { export interface QueryType {
name: string; name: string;
description?: string; description?: string;
@@ -11,41 +29,271 @@ export interface QueryType {
date: boolean; date: boolean;
dependsOnElement?: string; // Format for querySelector dependsOnElement?: string; // Format for querySelector
show?: boolean; show?: boolean;
localOnly?: boolean // Indicates can't be performed server-side.
} }
export interface SearchConfiguration { export interface SearchConfiguration {
filterArea: HTMLElement; filterArea: HTMLElement;
sortingByButton: HTMLButtonElement; sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement; searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement; notFoundPanel: HTMLElement;
notFoundLocallyText: HTMLElement;
notFoundCallback?: (notFound: boolean) => void; notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement; filterList: HTMLElement;
clearSearchButtonSelector: string; clearSearchButtonSelector: string;
serverSearchButtonSelector: string;
search: HTMLInputElement; search: HTMLInputElement;
queries: { [field: string]: QueryType }; queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void; setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void; onSearchCallback: (newItems: boolean, loadAll: boolean) => void;
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
clearServerSearch: () => void;
loadMore?: () => 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; matchesSearch: (query: string) => boolean;
} }
export const SearchableItemDataAttribute = "data-search-item";
export type SearchableItems = { [id: string]: SearchableItem };
export class Search { export class Search {
private _c: SearchConfiguration; private _c: SearchConfiguration;
private _sortField: string = "";
private _ascending: boolean = true;
private _ordering: string[] = []; private _ordering: string[] = [];
private _items: { [id: string]: SearchableItem }; private _items: SearchableItems = {};
inSearch: boolean; // 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[] => { // Intended to be set from the JS console, if true searches are timed.
this._c.filterArea.textContent = ""; timeSearches: boolean = false;
private _serverSearchButtons: HTMLElement[];
static tokenizeSearch = (query: string): string[] => {
query = query.toLowerCase(); query = query.toLowerCase();
let result: string[] = [...this._ordering];
let words: string[] = []; let words: string[] = [];
let quoteSymbol = ``; let quoteSymbol = ``;
let queryStart = -1; let queryStart = -1;
let lastQuote = -1; let lastQuote = -1;
@@ -75,174 +323,144 @@ export class Search {
} }
} }
words.push(query.substring(queryStart, end).replace(/['"]/g, "")); words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
console.log("pushed", words);
queryStart = -1; queryStart = -1;
} }
} }
} }
return words;
}
query = ""; parseTokens = (tokens: string[]): [string[], Query[]] => {
for (let word of words) { let queries: Query[] = [];
let searchTerms: string[] = [];
for (let word of tokens) {
// 1. Normal search text, no filters or anything
if (!word.includes(":")) { if (!word.includes(":")) {
let cachedResult = [...result]; searchTerms.push(word);
for (let id of cachedResult) {
const u = this._items[id];
if (!u.matchesSearch(word)) {
result.splice(result.indexOf(id), 1);
}
}
continue; continue;
} }
// 2. A filter query of some sort.
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)]; const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue; if (!(split[0] in this._c.queries)) continue;
const queryFormat = this._c.queries[split[0]]; const queryFormat = this._c.queries[split[0]];
if (queryFormat.bool) { let q: Query | null = null;
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>
`;
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 [`"`, `'`, ``]) { for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, ""); this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
} }
this._c.search.oninput((null as Event)); this._c.search.oninput((null as Event));
}) };
queries.push(q);
this._c.filterArea.appendChild(filterCard); continue;
// 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
} }
} }
if (queryFormat.string) { if (queryFormat.string) {
const filterCard = document.createElement("span"); q = new StringQuery(queryFormat, split[1]);
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]}"
`;
filterCard.addEventListener("click", () => { q.onclick = () => {
for (let quote of [`"`, `'`, ``]) { for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + 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.value = this._c.search.value.replace(regex, "");
} }
this._c.search.oninput((null as Event)); 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; continue;
} }
if (queryFormat.date) { if (queryFormat.date) {
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0 let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2)); if (!isDate) continue;
let unmodifiedValue = split[1]; q = new DateQuery(queryFormat, op, parsedDate);
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]); q.onclick = () => {
// 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]}
`;
filterCard.addEventListener("click", () => {
for (let quote of [`"`, `'`, ``]) { 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.value = this._c.search.value.replace(regex, "");
} }
this._c.search.oninput((null as Event)); this._c.search.oninput((null as Event));
}) }
queries.push(q);
continue;
}
// if (q != null) queries.push(q);
}
return [searchTerms, queries];
}
this._c.filterArea.appendChild(filterCard); // 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]; let cachedResult = [...result];
for (let id of cachedResult) { for (let id of cachedResult) {
const u = this._items[id]; const u = this.items[id];
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); 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) { if (unixValue == 0) {
result.splice(result.indexOf(id), 1); result.splice(result.indexOf(id), 1);
continue; continue;
} }
let value = new Date(unixValue*1000); 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]]; if (!q.compare(value)) {
// 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) {
result.splice(result.indexOf(id), 1); result.splice(result.indexOf(id), 1);
} }
} }
@@ -251,10 +469,34 @@ export class Search {
return result; 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 = () => { 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 != ""; const hasFilters = this._c.filterArea.textContent != "";
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
if (sortingBy || hasFilters) { if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden"); this._c.searchOptionsHeader.classList.remove("hidden");
} else { } else {
@@ -262,16 +504,26 @@ export class Search {
} }
} }
// -all- elements.
get items(): { [id: string]: SearchableItem } { return this._items; } get items(): { [id: string]: SearchableItem } { return this._items; }
set items(v: { [id: string]: SearchableItem }) { // set items(v: { [id: string]: SearchableItem }) {
this._items = v; // 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; } get sortField(): string { return this._sortField; }
set ordering(v: string[]) { this._ordering = v; } 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; const query = this._c.search.value;
if (!query) { if (!query) {
this.inSearch = false; this.inSearch = false;
@@ -279,15 +531,37 @@ export class Search {
this.inSearch = true; this.inSearch = true;
} }
const results = this.search(query); const results = this.search(query);
this._c.setVisibility(results, true); this._c.setVisibility(results, true, appendedItems);
this._c.onSearchCallback(results.length, newItems, loadAll); 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(); 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"); this._c.notFoundPanel.classList.remove("unfocused");
} else { } else {
this._c.notFoundPanel.classList.add("unfocused"); this._c.notFoundPanel.classList.add("unfocused");
} }
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
} }
fillInFilter = (name: string, value: string, offset?: number) => { fillInFilter = (name: string, value: string, offset?: number) => {
@@ -300,8 +574,6 @@ export class Search {
this._c.search.oninput(null as any); this._c.search.oninput(null as any);
}; };
generateFilterList = () => { generateFilterList = () => {
// Generate filter buttons // Generate filter buttons
for (let queryName of Object.keys(this._c.queries)) { for (let queryName of Object.keys(this._c.queries)) {
@@ -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) { constructor(c: SearchConfiguration) {
this._c = c; 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>; const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
for (let b of clearSearchButtons) { for (let b of clearSearchButtons) {
b.addEventListener("click", () => { b.addEventListener("click", () => {
this._c.search.value = ""; this._c.search.value = "";
this.inServerSearch = false;
this.onSearchBoxChange(); 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"; import { PageManager, Page } from "../modules/pages.js";
declare var window: GlobalWindow;
export interface Tab { export interface Tab {
page: Page; page: Page;
tabEl: HTMLDivElement; 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 = { let tab: Tab = {
page: null, page: null,
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement, 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("active");
tab.buttonEl.classList.remove("~urge"); tab.buttonEl.classList.remove("~urge");
tab.tabEl.classList.add("unfocused"); tab.tabEl.classList.add("unfocused");
if (unloadFunc) unloadFunc();
return true; return true;
}, },
shouldSkip: () => false, shouldSkip: () => false,
+27 -5
View File
@@ -91,7 +91,7 @@ declare interface NotificationBox {
declare interface Tabs { declare interface Tabs {
current: string; 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; switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
} }
@@ -150,10 +150,32 @@ interface inviteList {
loadInviteURL: () => void; loadInviteURL: () => void;
} }
// Finally added to typescript, dont need this anymore. interface paginatedDTO {
// declare interface SubmitEvent extends Event { last_page: boolean;
// submitter: HTMLInputElement; }
// }
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 config: Object;
declare var modifiedConfig: Object; declare var modifiedConfig: Object;
+1 -1
View File
@@ -749,7 +749,7 @@ const setCardOrder = (messageCard: HTMLElement) => {
// addValue += side.length; // addValue += side.length;
} }
console.log("Shortest order:", minHeightPerm); console.debug("Shortest order:", minHeightPerm);
}; };
const login = new Login(window.modals.login as Modal, "/my/", "opaque"); 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: default:
toPush = []string{} toPush = []string{}
} }
urlBase := app.getURLBase(gc)
if pusher := gc.Writer.Pusher(); pusher != nil { if pusher := gc.Writer.Pusher(); pusher != nil {
for _, f := range toPush { 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) 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) { func (app *appContext) AdminPage(gc *gin.Context) {
app.pushResources(gc, AdminPage) app.pushResources(gc, AdminPage)
// Pre-emptively (maybe) generate user cache
go app.userCache.MaybeSync(app)
lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang) lang := app.getLang(gc, AdminPage, app.storage.lang.chosenAdminLang)
jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) jfAdminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false) jfAllowAll := app.config.Section("ui").Key("allow_all").MustBool(false)