mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
Compare commits
27 Commits
paramateri
...
699cbee240
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699cbee240 | ||
|
|
ef253de56b | ||
|
|
9715f90a48 | ||
|
|
792296e3bc | ||
|
|
31d3e52229 | ||
|
|
4a92712c90 | ||
|
|
47188da5c2 | ||
|
|
bdae52fad7 | ||
|
|
1ec3ddad9f | ||
|
|
64a144034d | ||
|
|
d0f740f99d | ||
|
|
58c7b695c9 | ||
|
|
b19efc4ee6 | ||
|
|
8ba6131d22 | ||
|
|
c5683dbc71 | ||
|
|
3067db9c31 | ||
|
|
28440a9096 | ||
|
|
07d02f8302 | ||
|
|
01a75c3e23 | ||
|
|
4cc5fd7189 | ||
|
|
16c5420c6f | ||
|
|
eab33d9f6d | ||
|
|
471021623b | ||
|
|
e7f4de2202 | ||
|
|
44e8035ff0 | ||
|
|
e38ac62ae4 | ||
|
|
b47a481678 |
255
activitysort.go
Normal file
255
activitysort.go
Normal 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).Compare(ra.Field().(time.Time)) == int(operator), nil
|
||||
})
|
||||
return query
|
||||
}
|
||||
@@ -6,32 +6,6 @@ import (
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
func stringToActivityType(v string) ActivityType {
|
||||
switch v {
|
||||
case "creation":
|
||||
return ActivityCreation
|
||||
case "deletion":
|
||||
return ActivityDeletion
|
||||
case "disabled":
|
||||
return ActivityDisabled
|
||||
case "enabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "changePassword":
|
||||
return ActivityChangePassword
|
||||
case "resetPassword":
|
||||
return ActivityResetPassword
|
||||
case "createInvite":
|
||||
return ActivityCreateInvite
|
||||
case "deleteInvite":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
func activityTypeToString(v ActivityType) string {
|
||||
switch v {
|
||||
case ActivityCreation:
|
||||
@@ -58,6 +32,32 @@ func activityTypeToString(v ActivityType) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func stringToActivityType(v string) ActivityType {
|
||||
switch v {
|
||||
case "creation":
|
||||
return ActivityCreation
|
||||
case "deletion":
|
||||
return ActivityDeletion
|
||||
case "disabled":
|
||||
return ActivityDisabled
|
||||
case "enabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "changePassword":
|
||||
return ActivityChangePassword
|
||||
case "resetPassword":
|
||||
return ActivityResetPassword
|
||||
case "createInvite":
|
||||
return ActivityCreateInvite
|
||||
case "deleteInvite":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
func stringToActivitySource(v string) ActivitySource {
|
||||
switch v {
|
||||
case "user":
|
||||
@@ -88,71 +88,73 @@ func activitySourceToString(v ActivitySource) string {
|
||||
|
||||
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
|
||||
// @Produce json
|
||||
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
|
||||
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search parameters"
|
||||
// @Success 200 {object} GetActivitiesRespDTO
|
||||
// @Router /activity [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := GetActivitiesDTO{}
|
||||
req := ServerSearchReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
query := &badgerhold.Query{}
|
||||
activityTypes := make([]interface{}, len(req.Type))
|
||||
for i, v := range req.Type {
|
||||
activityTypes[i] = stringToActivityType(v)
|
||||
}
|
||||
if len(activityTypes) != 0 {
|
||||
query = badgerhold.Where("Type").In(activityTypes...)
|
||||
if req.SortByField == "" {
|
||||
req.SortByField = USER_DEFAULT_SORT_FIELD
|
||||
} else {
|
||||
req.SortByField = activityDTONameToField(req.SortByField)
|
||||
}
|
||||
|
||||
var query *badgerhold.Query
|
||||
if len(req.SearchTerms) != 0 {
|
||||
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
||||
} else {
|
||||
query = nil
|
||||
}
|
||||
|
||||
for _, q := range req.Queries {
|
||||
nq := q.AsDBQuery(query)
|
||||
if nq == nil {
|
||||
nq = ActivityDBQueryFromSpecialField(app.jf, query, q)
|
||||
}
|
||||
query = nq
|
||||
}
|
||||
|
||||
if query == nil {
|
||||
query = &badgerhold.Query{}
|
||||
}
|
||||
|
||||
query = query.SortBy(req.SortByField)
|
||||
if !req.Ascending {
|
||||
query = query.Reverse()
|
||||
}
|
||||
|
||||
query = query.SortBy("Time")
|
||||
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
|
||||
|
||||
var results []Activity
|
||||
err := app.storage.db.Find(&results, query)
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedDBReadActivities, err)
|
||||
}
|
||||
|
||||
resp := GetActivitiesRespDTO{
|
||||
Activities: make([]ActivityDTO, len(results)),
|
||||
LastPage: len(results) != req.Limit,
|
||||
}
|
||||
|
||||
resp.LastPage = len(results) != req.Limit
|
||||
for i, act := range results {
|
||||
resp.Activities[i] = ActivityDTO{
|
||||
ID: act.ID,
|
||||
Type: activityTypeToString(act.Type),
|
||||
UserID: act.UserID,
|
||||
SourceType: activitySourceToString(act.SourceType),
|
||||
Source: act.Source,
|
||||
InviteCode: act.InviteCode,
|
||||
Value: act.Value,
|
||||
Time: act.Time.Unix(),
|
||||
IP: act.IP,
|
||||
ID: act.ID,
|
||||
Type: activityTypeToString(act.Type),
|
||||
UserID: act.UserID,
|
||||
SourceType: activitySourceToString(act.SourceType),
|
||||
Source: act.Source,
|
||||
InviteCode: act.InviteCode,
|
||||
Value: act.Value,
|
||||
Time: act.Time.Unix(),
|
||||
IP: act.IP,
|
||||
Username: act.MustGetUsername(app.jf),
|
||||
SourceUsername: act.MustGetSourceUsername(app.jf),
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
resp.Activities[i].Username = act.Value
|
||||
// Username would've been in here, clear it to avoid confusion to the consumer
|
||||
resp.Activities[i].Value = ""
|
||||
} else if user, err := app.jf.UserByID(act.UserID, false); err == nil {
|
||||
resp.Activities[i].Username = user.Name
|
||||
}
|
||||
|
||||
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
|
||||
user, err := app.jf.UserByID(act.Source, false)
|
||||
if err == nil {
|
||||
resp.Activities[i].SourceUsername = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +175,12 @@ func (app *appContext) DeleteActivity(gc *gin.Context) {
|
||||
|
||||
// @Summary Returns the total number of activities stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetActivityCountDTO
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /activity/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
resp := GetActivityCountDTO{}
|
||||
resp := PageCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
|
||||
if err != nil {
|
||||
|
||||
81
api-users.go
81
api-users.go
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -337,8 +338,8 @@ func (app *appContext) PostNewUserFromInvite(nu NewUserData, req ConfirmationKey
|
||||
// FIXME: figure these out in a nicer way? this relies on the current ordering,
|
||||
// which may not be fixed.
|
||||
if discordEnabled {
|
||||
if req.completeContactMethods[0].User != nil {
|
||||
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
|
||||
if req.completeContactMethods[0].User != nil {
|
||||
discordUser = req.completeContactMethods[0].User.(*DiscordUser)
|
||||
}
|
||||
if telegramEnabled && req.completeContactMethods[1].User != nil {
|
||||
telegramUser = req.completeContactMethods[1].User.(*TelegramUser)
|
||||
@@ -894,7 +895,25 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
|
||||
|
||||
}
|
||||
|
||||
// @Summary Get a list of Jellyfin users.
|
||||
// @Summary Returns the total number of Jellyfin users.
|
||||
// @Produce json
|
||||
// @Success 200 {object} PageCountDTO
|
||||
// @Router /users/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetUserCount(gc *gin.Context) {
|
||||
resp := PageCountDTO{}
|
||||
userList, err := app.userCache.GetUserDTOs(app, false)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
resp.Count = uint64(len(userList))
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get a list of -all- Jellyfin users.
|
||||
// @Produce json
|
||||
// @Success 200 {object} getUsersDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
@@ -903,19 +922,61 @@ func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
|
||||
// @tags Users
|
||||
func (app *appContext) GetUsers(gc *gin.Context) {
|
||||
var resp getUsersDTO
|
||||
users, err := app.jf.GetUsers(false)
|
||||
resp.UserList = make([]respUser, len(users))
|
||||
// We're sending all users, so this is always true
|
||||
resp.LastPage = true
|
||||
var err error
|
||||
resp.UserList, err = app.userCache.GetUserDTOs(app, true)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
i := 0
|
||||
for _, jfUser := range users {
|
||||
user := app.userSummary(jfUser)
|
||||
resp.UserList[i] = user
|
||||
i++
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Get a paginated, searchable list of Jellyfin users.
|
||||
// @Produce json
|
||||
// @Param ServerSearchReqDTO body ServerSearchReqDTO true "search / pagination parameters"
|
||||
// @Success 200 {object} getUsersDTO
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /users [post]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) SearchUsers(gc *gin.Context) {
|
||||
req := ServerSearchReqDTO{}
|
||||
gc.BindJSON(&req)
|
||||
if req.SortByField == "" {
|
||||
req.SortByField = USER_DEFAULT_SORT_FIELD
|
||||
}
|
||||
|
||||
var resp getUsersDTO
|
||||
userList, err := app.userCache.GetUserDTOs(app, req.SortByField == USER_DEFAULT_SORT_FIELD)
|
||||
if err != nil {
|
||||
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
|
||||
respond(500, "Couldn't get users", gc)
|
||||
return
|
||||
}
|
||||
var filtered []*respUser
|
||||
if len(req.SearchTerms) != 0 || len(req.Queries) != 0 {
|
||||
filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
|
||||
} else {
|
||||
filtered = slices.Clone(userList)
|
||||
}
|
||||
|
||||
if req.SortByField == USER_DEFAULT_SORT_FIELD {
|
||||
if req.Ascending != USER_DEFAULT_SORT_ASCENDING {
|
||||
slices.Reverse(filtered)
|
||||
}
|
||||
} else {
|
||||
app.userCache.Sort(filtered, req.SortByField, req.Ascending)
|
||||
}
|
||||
|
||||
startIndex := (req.Page * req.Limit)
|
||||
if startIndex < len(filtered) {
|
||||
endIndex := min(startIndex+req.Limit, len(filtered))
|
||||
resp.UserList = filtered[startIndex:endIndex]
|
||||
}
|
||||
resp.LastPage = len(resp.UserList) != req.Limit
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
|
||||
43
auth.go
43
auth.go
@@ -165,6 +165,31 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
|
||||
return
|
||||
}
|
||||
|
||||
func (app *appContext) canAccessAdminPage(user mediabrowser.User, emailStore EmailAddress) bool {
|
||||
// 1. "Allow all" is enabled, so simply being a user implies access.
|
||||
if app.config.Section("ui").Key("allow_all").MustBool(false) && user.ID != "" {
|
||||
return true
|
||||
}
|
||||
// 2. You've been made an "accounts admin" from the accounts tab.
|
||||
if emailStore.Admin {
|
||||
return true
|
||||
}
|
||||
// 3. (Jellyfin) "Admins only" is enabled, and you're one.
|
||||
if app.config.Section("ui").Key("admin_only").MustBool(true) && user.ID != "" && user.Policy.IsAdministrator {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *appContext) canAccessAdminPageByID(jfID string) bool {
|
||||
user, err := app.jf.UserByID(jfID, false)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
emailStore, _ := app.storage.GetEmailsKey(jfID)
|
||||
return app.canAccessAdminPage(user, emailStore)
|
||||
}
|
||||
|
||||
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) {
|
||||
ok = false
|
||||
user, err := app.authJf.Authenticate(username, password)
|
||||
@@ -220,18 +245,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
jfID = user.ID
|
||||
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
|
||||
accountsAdmin := false
|
||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||
if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
|
||||
accountsAdmin = emailStore.Admin
|
||||
}
|
||||
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
|
||||
if !accountsAdmin {
|
||||
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
emailStore, _ := app.storage.GetEmailsKey(jfID)
|
||||
accountsAdmin := app.canAccessAdminPage(user, emailStore)
|
||||
if !accountsAdmin {
|
||||
app.authLog(fmt.Sprintf(lm.NonAdminUser, username))
|
||||
respond(401, "Unauthorized", gc)
|
||||
return
|
||||
}
|
||||
// New users are only added when using jellyfinLogin.
|
||||
userID = shortuuid.New()
|
||||
|
||||
31
config.go
31
config.go
@@ -45,6 +45,13 @@ func (app *appContext) MustSetURLPath(section, key, val string) {
|
||||
app.MustSetValue(section, key, val)
|
||||
}
|
||||
|
||||
func FixFullURL(v string) string {
|
||||
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
|
||||
v = "http://" + v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func FormatSubpath(path string) string {
|
||||
if path == "/" {
|
||||
return ""
|
||||
@@ -52,6 +59,15 @@ func FormatSubpath(path string) string {
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
func (app *appContext) MustCorrectURL(section, key, value string) {
|
||||
v := app.config.Section(section).Key(key).String()
|
||||
if v == "" {
|
||||
v = value
|
||||
}
|
||||
v = FixFullURL(v)
|
||||
app.config.Section(section).Key(key).SetValue(v)
|
||||
}
|
||||
|
||||
func (app *appContext) loadConfig() error {
|
||||
var err error
|
||||
app.config, err = ini.ShadowLoad(app.configPath)
|
||||
@@ -75,8 +91,10 @@ func (app *appContext) loadConfig() error {
|
||||
app.err.Printf(lm.BadURLBase, PAGES.Base)
|
||||
}
|
||||
app.info.Printf(lm.SubpathBlockMessage, PAGES.Base, PAGES.Admin, PAGES.MyAccount, PAGES.Form)
|
||||
app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
app.MustSetValue("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
|
||||
app.MustCorrectURL("jellyfin", "server", "")
|
||||
app.MustCorrectURL("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
|
||||
app.MustCorrectURL("ui", "redirect_url", app.config.Section("jellyfin").Key("public_server").String())
|
||||
|
||||
for _, key := range app.config.Section("files").Keys() {
|
||||
if name := key.Name(); name != "html_templates" && name != "lang_files" {
|
||||
@@ -133,11 +151,6 @@ func (app *appContext) loadConfig() error {
|
||||
sc := app.config.Section("discord").Key("start_command").MustString("start")
|
||||
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
jfUrl := app.config.Section("jellyfin").Key("server").String()
|
||||
if !(strings.HasPrefix(jfUrl, "http://") || strings.HasPrefix(jfUrl, "https://")) {
|
||||
app.config.Section("jellyfin").Key("server").SetValue("http://" + jfUrl)
|
||||
}
|
||||
|
||||
// Deletion template is good enough for these as well.
|
||||
app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html")
|
||||
app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt")
|
||||
@@ -175,6 +188,10 @@ func (app *appContext) loadConfig() error {
|
||||
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
|
||||
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
|
||||
|
||||
app.MustSetValue("jellyfin", "cache_timeout", "30")
|
||||
app.MustSetValue("jellyfin", "web_cache_async_timeout", "1")
|
||||
app.MustSetValue("jellyfin", "web_cache_sync_timeout", "10")
|
||||
|
||||
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
|
||||
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
|
||||
|
||||
|
||||
@@ -65,6 +65,20 @@ sections:
|
||||
type: number
|
||||
value: 30
|
||||
description: Timeout of user cache in minutes. Set to 0 to disable.
|
||||
- setting: web_cache_async_timeout
|
||||
name: User search cache asynchronous timeout (minutes)
|
||||
requires_restart: true
|
||||
advanced: true
|
||||
type: number
|
||||
value: 1
|
||||
description: "Synchronise after cache is this old, but don't wait for it: The accounts tab will load quickly but show old results until the next request."
|
||||
- setting: web_cache_sync_timeout
|
||||
name: User search cache synchronous timeout (minutes)
|
||||
requires_restart: true
|
||||
advanced: true
|
||||
type: number
|
||||
value: 10
|
||||
description: "Synchronise after cache is this old, and wait for it: The accounts tab may take a little longer to load while it does."
|
||||
- setting: type
|
||||
name: Server type
|
||||
requires_restart: true
|
||||
@@ -1292,6 +1306,7 @@ sections:
|
||||
on account creation, and to automatically link contact methods (email, discord
|
||||
and telegram). A template must be added to a User Profile for accounts to be
|
||||
created.
|
||||
wiki_link: https://wiki.jfa-go.com/docs/external-services/jellyseerr/
|
||||
settings:
|
||||
- setting: enabled
|
||||
name: Enabled
|
||||
@@ -1318,7 +1333,7 @@ sections:
|
||||
requires_restart: true
|
||||
type: text
|
||||
depends_true: enabled
|
||||
description: API Key. Get this from the first tab in Jellyseerr's settings.
|
||||
description: API Key. Get this from the first tab in Jellyseerr's settings (NOT the "Jellyfin" tab!)
|
||||
- setting: import_existing
|
||||
name: Import existing users to Jellyseerr
|
||||
requires_restart: true
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tooltip.above .content {
|
||||
bottom: 2.5rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.tooltip.darker .content {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
13
discord.go
13
discord.go
@@ -612,11 +612,16 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
|
||||
//if mins > 0 {
|
||||
// expmin = mins
|
||||
//}
|
||||
// Check whether requestor is linked to the admin account
|
||||
requesterEmail, ok := d.app.storage.GetEmailsKey(requester.JellyfinID)
|
||||
if !(ok && requesterEmail.Admin) {
|
||||
// We want the same criteria for running this command as accessing the admin page (i.e. an "admin" of some sort)
|
||||
if !(d.app.canAccessAdminPageByID(requester.JellyfinID)) {
|
||||
d.app.err.Printf(lm.FailedGenerateInvite, fmt.Sprintf(lm.NonAdminUser, requester.JellyfinID))
|
||||
// FIXME: add response message
|
||||
s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
|
||||
Type: dg.InteractionResponseChannelMessageWithSource,
|
||||
Data: &dg.InteractionResponseData{
|
||||
Content: d.app.storage.lang.Telegram[lang].Strings.get("noPermission"),
|
||||
Flags: 64, // Ephemeral
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -43,7 +43,6 @@ require (
|
||||
github.com/hrfee/jfa-go/logger v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/logmessages v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/jfa-go/ombi v0.0.0-20241105225412-da4470bc4fbc
|
||||
github.com/hrfee/mediabrowser v0.3.24
|
||||
github.com/itchyny/timefmt-go v0.1.6
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mailgun/mailgun-go/v4 v4.18.1
|
||||
@@ -98,6 +97,7 @@ require (
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hrfee/mediabrowser v0.3.27 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -205,8 +205,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hrfee/mediabrowser v0.3.24 h1:cT5+X3bZeaSBQFevMYkFIw6JJ8nW7Myvb+11a2/THMA=
|
||||
github.com/hrfee/mediabrowser v0.3.24/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/hrfee/mediabrowser v0.3.27 h1:8bxPamBFLD1Xqy6pf6M3Oc5GUQ0iU/flO0S64G1AsIM=
|
||||
github.com/hrfee/mediabrowser v0.3.27/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
|
||||
@@ -722,9 +722,17 @@
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
|
||||
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<div class="tooltip left">
|
||||
<button class="button ~info @low center h-full accounts-search-server flex flex-row gap-1" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}">
|
||||
<i class="ri-search-line"></i>
|
||||
<span>{{ .strings.searchAll }}</span>
|
||||
</button>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
@@ -738,6 +746,7 @@
|
||||
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
<span id="accounts-filter-area"></span>
|
||||
</div>
|
||||
<div class="supra sm flex flex-row gap-2" id="accounts-record-counter"></div>
|
||||
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
|
||||
<div class="flex flex-row flex-wrap gap-3 mb-4">
|
||||
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
|
||||
@@ -802,14 +811,26 @@
|
||||
</thead>
|
||||
<tbody id="accounts-list"></tbody>
|
||||
</table>
|
||||
<div id="accounts-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="accounts-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<button class="button ~neutral @low accounts-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low accounts-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 m-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>
|
||||
@@ -824,10 +845,14 @@
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row align-middle w-full">
|
||||
<div class="flex flex-row align-middle w-full gap-2">
|
||||
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
<div class="tooltip left">
|
||||
<button class="button ~info @low center h-full activity-search-server" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"><i class="ri-search-line"></i></button>
|
||||
<span class="content sm">{{ .strings.searchAllRecords }}</span>
|
||||
</div>
|
||||
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-display max-w-full">
|
||||
@@ -838,11 +863,7 @@
|
||||
</div>
|
||||
<div class="flex flex-row justify-between pt-3 pb-2">
|
||||
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="supra sm flex flex-row gap-2">
|
||||
<span id="activity-total-records"></span>
|
||||
<span id="activity-loaded-records"></span>
|
||||
<span id="activity-shown-records"></span>
|
||||
</div>
|
||||
<div class="supra sm flex flex-row gap-2" id="activity-record-counter"></div>
|
||||
</div>
|
||||
<div class="row -mx-2 mb-2">
|
||||
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
@@ -852,9 +873,9 @@
|
||||
<div id="activity-card-list"></div>
|
||||
<div id="activity-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="activity-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
|
||||
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3 text-center">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low activity-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
@@ -863,9 +884,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
|
||||
<div class="flex flex-row gap-2 m-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>
|
||||
|
||||
@@ -557,12 +557,13 @@
|
||||
<div class="card lg:container sectioned ~neutral @low unfocused">
|
||||
<section class="section flex flex-col gap-2 justify-center items-center">
|
||||
<span class="heading">{{ .lang.EndPage.finished }}</span>
|
||||
<p class="content text-center">{{ .lang.EndPage.restartMessage }} {{ .lang.EndPage.urlChangedNotice }}</p>
|
||||
<p class="content text-center">{{ .lang.EndPage.moreFeatures }} {{ .lang.EndPage.restartReload }} {{ .lang.EndPage.ifFailedLoad }}</p>
|
||||
</section>
|
||||
<section class="section w-full ~neutral footer flex flex-row justify-center items-center gap-2">
|
||||
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
|
||||
<span class="button ~urge @low" id="restart">{{ .lang.Strings.submit }}</span>
|
||||
<span class="button ~urge @low unfocused" id="refresh">{{ .lang.EndPage.refreshPage }}</span>
|
||||
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-internal"></a>
|
||||
<a class="button ~urge @low flex flex-col gap-0.5 unfocused" id="refresh-external"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"disabled": "Disabled",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"noResultsFound": "No Results Found",
|
||||
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",
|
||||
"keepSearching": "Keep Searching",
|
||||
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
|
||||
"contactThrough": "Contact through:",
|
||||
@@ -136,6 +137,8 @@
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
"searchAll": "Search/sort all",
|
||||
"searchAllRecords": "Search/sort all records (on server)",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Search Options",
|
||||
"matchText": "Match Text",
|
||||
@@ -190,6 +193,7 @@
|
||||
"totalRecords": "{n} Total Records",
|
||||
"loadedRecords": "{n} Loaded",
|
||||
"shownRecords": "{n} Shown",
|
||||
"selectedRecords": "{n} Selected",
|
||||
"backups": "Backups",
|
||||
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
|
||||
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"delete": "Delete",
|
||||
"myAccount": "My Account",
|
||||
"referrals": "Referrals",
|
||||
"inviteRemainingUses": "Remaining uses"
|
||||
"inviteRemainingUses": "Remaining uses",
|
||||
"internal": "Internal",
|
||||
"external": "External"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "The username and/or password were left blank.",
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Finished!",
|
||||
"restartMessage": "Features like Discord/Telegram/Matrix bots, custom Markdown messages, and a user-accessible \"My Account\" page can be found in Settings, so make sure to give it a browse. Click below to restart, then refresh the page.",
|
||||
"urlChangedNotice": "If you've changed the host, port, subfolder etc. that jfa-go is hosted on, check the URL is right.",
|
||||
"moreFeatures": "Tons more features like Discord/Telegram/Matrix bots and custom Markdown messages can be found in Settings, so make sure to give it a browse.",
|
||||
"restartReload": "Click below to restart, then access jfa-go at one of the given internal/external URLs.",
|
||||
"ifFailedLoad": "If it doesn't load, check the application's logs for any clues as to why.",
|
||||
"refreshPage": "Refresh"
|
||||
},
|
||||
"language": {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"languageSet": "Language set to {language}.",
|
||||
"discordDMs": "Please check your DMs for a response.",
|
||||
"sentInvite": "Sent invite.",
|
||||
"sentInviteFailure": "Failed to send invite, check logs."
|
||||
"sentInviteFailure": "Failed to send invite, check logs.",
|
||||
"noPermission": "You do not have permissions for this action."
|
||||
}
|
||||
}
|
||||
|
||||
2
log.go
2
log.go
@@ -59,7 +59,7 @@ func logOutput() (closeFunc func(), err error) {
|
||||
|
||||
// Regex that removes ANSI color escape sequences. Used for outputting to log file and log cache.
|
||||
var stripColors = func() *regexp.Regexp {
|
||||
r, err := regexp.Compile("\\x1b\\[[0-9;]*m")
|
||||
r, err := regexp.Compile(`\x1b\[[0-9;]*m`)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to compile color escape regexp: %v", err)
|
||||
}
|
||||
|
||||
@@ -118,8 +118,7 @@ const (
|
||||
SetAdminNotify = "Set \"%s\" to %t for admin address \"%s\""
|
||||
|
||||
// *jellyseerr*.go
|
||||
FailedGetUsers = "Failed to get user(s) from %s: %v"
|
||||
// FIXME: Once done, look back at uses of FailedGetUsers for places where this would make more sense.
|
||||
FailedGetUsers = "Failed to get user(s) from %s: %v"
|
||||
FailedGetUser = "Failed to get user \"%s\" from %s: %v"
|
||||
FailedGetJellyseerrNotificationPrefs = "Failed to get user \"%s\"'s notification prefs from " + Jellyseerr + ": %v"
|
||||
FailedSyncContactMethods = "Failed to sync contact methods with %s: %v"
|
||||
|
||||
10
main.go
10
main.go
@@ -134,6 +134,7 @@ type appContext struct {
|
||||
pwrCaptchas map[string]Captcha
|
||||
ConfirmationKeys map[string]map[string]ConfirmationKey // Map of invite code to jwt to request
|
||||
confirmationKeysLock sync.Mutex
|
||||
userCache *UserCache
|
||||
}
|
||||
|
||||
func generateSecret(length int) (string, error) {
|
||||
@@ -405,7 +406,7 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
// Initialize jellyfin/emby connection
|
||||
server := app.config.Section("jellyfin").Key("server").String()
|
||||
cacheTimeout := int(app.config.Section("jellyfin").Key("cache_timeout").MustUint(30))
|
||||
cacheTimeout := app.config.Section("jellyfin").Key("cache_timeout").MustInt()
|
||||
stringServerType := app.config.Section("jellyfin").Key("type").String()
|
||||
timeoutHandler := mediabrowser.NewNamedTimeoutHandler("Jellyfin", "\""+server+"\"", true)
|
||||
if stringServerType == "emby" {
|
||||
@@ -468,6 +469,11 @@ func start(asDaemon, firstCall bool) {
|
||||
}
|
||||
}
|
||||
|
||||
app.userCache = NewUserCache(
|
||||
time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_async_timeout").MustInt()),
|
||||
time.Minute*time.Duration(app.config.Section("jellyfin").Key("web_cache_sync_timeout").MustInt()),
|
||||
)
|
||||
|
||||
// Since email depends on language, the email reload in loadConfig won't work first time.
|
||||
// Email also handles its own proxying, as (SMTP atleast) doesn't use a HTTP transport.
|
||||
app.email = NewEmailer(app)
|
||||
@@ -527,7 +533,7 @@ func start(asDaemon, firstCall bool) {
|
||||
|
||||
// NOTE: The order in which these are placed in app.contactMethods matters.
|
||||
// Add new ones to the end.
|
||||
// FIXME: Add proxies.
|
||||
// Proxies are added a little later through ContactMethodLinker[].SetTransport.
|
||||
if discordEnabled {
|
||||
app.discord, err = newDiscordDaemon(app)
|
||||
if err != nil {
|
||||
|
||||
25
models.go
25
models.go
@@ -164,8 +164,20 @@ type respUser struct {
|
||||
ReferralsEnabled bool `json:"referrals_enabled"`
|
||||
}
|
||||
|
||||
type PaginatedDTO struct {
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type PaginatedReqDTO struct {
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
SortByField string `json:"sortByField"`
|
||||
Ascending bool `json:"ascending"`
|
||||
}
|
||||
|
||||
type getUsersDTO struct {
|
||||
UserList []respUser `json:"users"`
|
||||
UserList []*respUser `json:"users"`
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type ombiUser struct {
|
||||
@@ -429,19 +441,12 @@ type ActivityDTO struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type GetActivitiesDTO struct {
|
||||
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
Ascending bool `json:"ascending"`
|
||||
}
|
||||
|
||||
type GetActivitiesRespDTO struct {
|
||||
PaginatedDTO
|
||||
Activities []ActivityDTO `json:"activities"`
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type GetActivityCountDTO struct {
|
||||
type PageCountDTO struct {
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@af-utils/scrollend-polyfill": "^0.0.14",
|
||||
"@ts-stack/markdown": "^1.4.0",
|
||||
"@types/node": "^20.3.0",
|
||||
"a17t": "^0.10.1",
|
||||
@@ -36,6 +37,12 @@
|
||||
"esbuild": "^0.18.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@af-utils/scrollend-polyfill": {
|
||||
"version": "0.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz",
|
||||
"integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
@@ -7293,6 +7300,11 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@af-utils/scrollend-polyfill": {
|
||||
"version": "0.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz",
|
||||
"integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ=="
|
||||
},
|
||||
"@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/hrfee/jfa-go#readme",
|
||||
"dependencies": {
|
||||
"@af-utils/scrollend-polyfill": "^0.0.14",
|
||||
"@ts-stack/markdown": "^1.4.0",
|
||||
"@types/node": "^20.3.0",
|
||||
"a17t": "^0.10.1",
|
||||
|
||||
@@ -136,7 +136,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
router.GET(p+"/token/login", app.getTokenLogin)
|
||||
router.GET(p+"/token/refresh", app.getTokenRefresh)
|
||||
router.POST(p+"/user/invite", app.NewUserFromInvite)
|
||||
router.Use(static.Serve(p+PAGES.Form, app.webFS))
|
||||
router.Use(static.Serve(p+PAGES.Form+"/", app.webFS))
|
||||
router.GET(p+PAGES.Form+"/:invCode", app.InviteProxy)
|
||||
if app.config.Section("captcha").Key("enabled").MustBool(false) {
|
||||
router.GET(p+"/captcha/gen/:invCode", app.GenCaptcha)
|
||||
@@ -183,6 +183,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
router.POST(p+"/logout", app.Logout)
|
||||
api.DELETE(p+"/users", app.DeleteUsers)
|
||||
api.GET(p+"/users", app.GetUsers)
|
||||
api.GET(p+"/users/count", app.GetUserCount)
|
||||
api.POST(p+"/users", app.SearchUsers)
|
||||
api.POST(p+"/user", app.NewUserFromAdmin)
|
||||
api.POST(p+"/users/extend", app.ExtendExpiry)
|
||||
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab"}
|
||||
names = []string{"Aaron", "Agnes", "Bridget", "Brandon", "Dolly", "Drake", "Elizabeth", "Erika", "Geoff", "Graham", "Haley", "Halsey", "Josie", "John", "Kayleigh", "Luka", "Melissa", "Nasreen", "Paul", "Ross", "Sam", "Talib", "Veronika", "Zaynab", "Graig", "Rhoda", "Tyler", "Quentin", "Melinda", "Zelma", "Jack", "Clifton", "Sherry", "Boyce", "Elma", "Jere", "Shelby", "Caitlin", "Bertie", "Mallory", "Thelma", "Charley", "Santo", "Merrill", "Royal", "Jefferson", "Ester", "Dee", "Susanna", "Adriana", "Alfonso", "Lillie", "Carmen", "Federico", "Ernie", "Kory", "Kimberly", "Donn", "Lilian", "Irvin", "Sherri", "Cordell", "Adrienne", "Edwin", "Serena", "Otis", "Latasha", "Johanna", "Clarence", "Noe", "Mindy", "Felix", "Audra"}
|
||||
COUNT = 4000
|
||||
DELAY = 1 * time.Millisecond
|
||||
)
|
||||
|
||||
const (
|
||||
PASSWORD = "test"
|
||||
COUNT = 10
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -57,6 +58,12 @@ func main() {
|
||||
password = strings.TrimSuffix(password, "\n")
|
||||
}
|
||||
|
||||
if countEnv := os.Getenv("COUNT"); countEnv != "" {
|
||||
COUNT, _ = strconv.Atoi(countEnv)
|
||||
}
|
||||
|
||||
fmt.Printf("Will generate %d users\n", COUNT)
|
||||
|
||||
jf, err := mediabrowser.NewServer(
|
||||
mediabrowser.JellyfinServer,
|
||||
server,
|
||||
@@ -95,11 +102,12 @@ func main() {
|
||||
rand.Seed(time.Now().Unix())
|
||||
|
||||
for i := 0; i < COUNT; i++ {
|
||||
name := names[rand.Intn(len(names))] + strconv.Itoa(rand.Intn(100))
|
||||
name := names[rand.Intn(len(names))] + strconv.Itoa(rand.Intn(500))
|
||||
|
||||
user, status, err := jf.NewUser(name, PASSWORD)
|
||||
if (status != 200 && status != 201 && status != 204) || err != nil {
|
||||
log.Fatalf("Failed to create user \"%s\" (%d): %+v\n", name, status, err)
|
||||
log.Printf("Acc no %d: Failed to create user \"%s\" (%d): %+v\n", i, name, status, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if rand.Intn(100) > 65 {
|
||||
@@ -110,13 +118,17 @@ func main() {
|
||||
user.Policy.IsDisabled = true
|
||||
}
|
||||
|
||||
time.Sleep(DELAY / 4)
|
||||
status, err = jf.SetPolicy(user.ID, user.Policy)
|
||||
if (status != 200 && status != 201 && status != 204) || err != nil {
|
||||
log.Fatalf("Failed to set policy for user \"%s\" (%d): %+v\n", name, status, err)
|
||||
log.Fatalf("Acc no %d: Failed to set policy for user \"%s\" (%d): %+v\n", i, name, status, err)
|
||||
}
|
||||
|
||||
if rand.Intn(100) > 20 {
|
||||
time.Sleep(DELAY / 4)
|
||||
jfTemp.Authenticate(name, PASSWORD)
|
||||
}
|
||||
log.Printf("Acc %d done\n", i)
|
||||
time.Sleep(DELAY / 4)
|
||||
}
|
||||
}
|
||||
|
||||
15
ts/admin.ts
15
ts/admin.ts
@@ -127,7 +127,7 @@ let isInviteURL = window.invites.isInviteURL();
|
||||
let isAccountURL = accounts.isAccountURL();
|
||||
|
||||
// load tabs
|
||||
const tabs: { id: string, url: string, reloader: () => void }[] = [
|
||||
const tabs: { id: string, url: string, reloader: () => void, unloader?: () => void }[] = [
|
||||
{
|
||||
id: "invites",
|
||||
url: "",
|
||||
@@ -148,12 +148,19 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [
|
||||
// Don't keep loading the same item on every tab refresh
|
||||
isAccountURL = false;
|
||||
}
|
||||
accounts.bindPageEvents();
|
||||
}),
|
||||
unloader: accounts.unbindPageEvents
|
||||
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
url: "activity",
|
||||
reloader: activity.reload
|
||||
reloader: () => {
|
||||
activity.reload()
|
||||
activity.bindPageEvents();
|
||||
},
|
||||
unloader: activity.unbindPageEvents
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
@@ -167,7 +174,7 @@ const defaultTab = tabs[0];
|
||||
window.tabs = new Tabs();
|
||||
|
||||
for (let tab of tabs) {
|
||||
window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader);
|
||||
window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader, tab.unloader || null);
|
||||
}
|
||||
|
||||
let matchedTab = false
|
||||
@@ -188,7 +195,7 @@ login.onLogin = () => {
|
||||
window.updater = new Updater();
|
||||
// FIXME: Decide whether to autoload activity or not
|
||||
reloadProfileNames();
|
||||
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
|
||||
setInterval(() => { window.invites.reload(); accounts.reloadIfNotInScroll(); }, 30*1000);
|
||||
// Triggers pre and post funcs, even though we're already on that page
|
||||
window.tabs.switch(window.tabs.current);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
|
||||
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
|
||||
import { _get, _post, _delete, toDateString } from "../modules/common.js";
|
||||
import { SearchConfiguration, QueryType, SearchableItem, SearchableItems, SearchableItemDataAttribute } from "../modules/search.js";
|
||||
import { accountURLEvent } from "../modules/accounts.js";
|
||||
import { inviteURLEvent } from "../modules/invites.js";
|
||||
import { PaginatedList } from "./list.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
const ACTIVITY_DEFAULT_SORT_FIELD = "time";
|
||||
const ACTIVITY_DEFAULT_SORT_ASCENDING = false;
|
||||
|
||||
export interface activity {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -32,6 +36,124 @@ var activityTypeMoods = {
|
||||
"deleteInvite": -1
|
||||
};
|
||||
|
||||
// window.lang doesn't exist at page load, so I made this a function that's invoked by activityList.
|
||||
const queries = (): { [field: string]: QueryType } => { return {
|
||||
"id": {
|
||||
name: window.lang.strings("activityID"),
|
||||
getter: "id",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"title": {
|
||||
name: window.lang.strings("title"),
|
||||
getter: "title",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false,
|
||||
localOnly: true
|
||||
},
|
||||
"user": {
|
||||
name: window.lang.strings("usersMentioned"),
|
||||
getter: "mentionedUsers",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"actor": {
|
||||
name: window.lang.strings("actor"),
|
||||
description: window.lang.strings("actorDescription"),
|
||||
getter: "actor",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"referrer": {
|
||||
name: window.lang.strings("referrer"),
|
||||
getter: "referrer",
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"time": {
|
||||
name: window.lang.strings("date"),
|
||||
getter: "time",
|
||||
bool: false,
|
||||
string: false,
|
||||
date: true
|
||||
},
|
||||
"account-creation": {
|
||||
name: window.lang.strings("accountCreationFilter"),
|
||||
getter: "accountCreation",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-deletion": {
|
||||
name: window.lang.strings("accountDeletionFilter"),
|
||||
getter: "accountDeletion",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-disabled": {
|
||||
name: window.lang.strings("accountDisabledFilter"),
|
||||
getter: "accountDisabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-enabled": {
|
||||
name: window.lang.strings("accountEnabledFilter"),
|
||||
getter: "accountEnabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-linked": {
|
||||
name: window.lang.strings("contactLinkedFilter"),
|
||||
getter: "contactLinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-unlinked": {
|
||||
name: window.lang.strings("contactUnlinkedFilter"),
|
||||
getter: "contactUnlinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-change": {
|
||||
name: window.lang.strings("passwordChangeFilter"),
|
||||
getter: "passwordChange",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-reset": {
|
||||
name: window.lang.strings("passwordResetFilter"),
|
||||
getter: "passwordReset",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-created": {
|
||||
name: window.lang.strings("inviteCreatedFilter"),
|
||||
getter: "inviteCreated",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-deleted": {
|
||||
name: window.lang.strings("inviteDeletedFilter"),
|
||||
getter: "inviteDeleted",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
}
|
||||
}};
|
||||
|
||||
// var moodColours = ["~warning", "~neutral", "~urge"];
|
||||
|
||||
export var activityReload = new CustomEvent("activity-reload");
|
||||
@@ -48,16 +170,6 @@ export class Activity implements activity, SearchableItem {
|
||||
private _delete: HTMLElement;
|
||||
private _ip: HTMLElement;
|
||||
private _act: activity;
|
||||
/* private _urlBase: string = ((): string => {
|
||||
let link = window.location.href;
|
||||
for (let split of ["#", "?", "/activity"]) {
|
||||
link = link.split(split)[0];
|
||||
}
|
||||
if (link.slice(-1) != "/") { link += "/"; }
|
||||
// FIXME: I should probably just be using window.pages.Base, but incase thats not right, i'll put this warning here
|
||||
if (link != window.pages.Base) console.error(`URL Bases don't match: "${link}" != "${window.pages.Base}"`);
|
||||
return link;
|
||||
})(); */
|
||||
|
||||
_genUserText = (): string => {
|
||||
return `<span class="font-medium">${this._act.username || this._act.user_id.substring(0, 5)}</span>`;
|
||||
@@ -242,7 +354,10 @@ export class Activity implements activity, SearchableItem {
|
||||
}
|
||||
|
||||
get id(): string { return this._act.id; }
|
||||
set id(v: string) { this._act.id = v; }
|
||||
set id(v: string) {
|
||||
this._act.id = v;
|
||||
this._card.setAttribute(SearchableItemDataAttribute, v);
|
||||
}
|
||||
|
||||
get user_id(): string { return this._act.user_id; }
|
||||
set user_id(v: string) { this._act.user_id = v; }
|
||||
@@ -268,6 +383,7 @@ export class Activity implements activity, SearchableItem {
|
||||
this._card = document.createElement("div");
|
||||
|
||||
this._card.classList.add("card", "@low", "my-2");
|
||||
|
||||
this._card.innerHTML = `
|
||||
<div class="flex flex-col md:flex-row justify-between mb-2">
|
||||
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span>
|
||||
@@ -356,343 +472,126 @@ export class Activity implements activity, SearchableItem {
|
||||
asElement = () => { return this._card; };
|
||||
}
|
||||
|
||||
interface ActivitiesDTO {
|
||||
interface ActivitiesReqDTO extends PaginatedReqDTO {
|
||||
type: string[];
|
||||
};
|
||||
|
||||
interface ActivitiesDTO extends paginatedDTO {
|
||||
activities: activity[];
|
||||
last_page: boolean;
|
||||
}
|
||||
|
||||
export class activityList {
|
||||
private _activityList: HTMLElement;
|
||||
private _activities: { [id: string]: Activity } = {};
|
||||
private _ordering: string[] = [];
|
||||
private _filterArea = document.getElementById("activity-filter-area");
|
||||
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
|
||||
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
|
||||
private _notFoundPanel = document.getElementById("activity-not-found");
|
||||
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
|
||||
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
private _loader = document.getElementById("activity-loader");
|
||||
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
|
||||
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
|
||||
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
|
||||
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
|
||||
private _keepSearchingButton = document.getElementById("activity-keep-searching");
|
||||
export class activityList extends PaginatedList {
|
||||
protected _container: HTMLElement;
|
||||
// protected _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
|
||||
protected _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
|
||||
private _totalRecords = document.getElementById("activity-total-records");
|
||||
private _loadedRecords = document.getElementById("activity-loaded-records");
|
||||
private _shownRecords = document.getElementById("activity-shown-records");
|
||||
|
||||
private _total: number;
|
||||
private _loaded: number;
|
||||
private _shown: number;
|
||||
|
||||
get total(): number { return this._total; }
|
||||
set total(v: number) {
|
||||
this._total = v;
|
||||
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
}
|
||||
protected _ascending: boolean;
|
||||
|
||||
get loaded(): number { return this._loaded; }
|
||||
set loaded(v: number) {
|
||||
this._loaded = v;
|
||||
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
}
|
||||
get activities(): { [id: string]: Activity } { return this._search.items as { [id: string]: Activity }; }
|
||||
// set activities(v: { [id: string]: Activity }) { this._search.items = v as SearchableItems; }
|
||||
|
||||
get shown(): number { return this._shown; }
|
||||
set shown(v: number) {
|
||||
this._shown = v;
|
||||
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
}
|
||||
|
||||
private _search: Search;
|
||||
private _ascending: boolean;
|
||||
private _hasLoaded: boolean;
|
||||
private _lastLoad: number;
|
||||
private _page: number = 0;
|
||||
private _lastPage: boolean;
|
||||
|
||||
|
||||
setVisibility = (activities: string[], visible: boolean) => {
|
||||
this._activityList.textContent = ``;
|
||||
for (let id of this._ordering) {
|
||||
if (visible && activities.indexOf(id) != -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
} else if (!visible && activities.indexOf(id) == -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
constructor() {
|
||||
super({
|
||||
loader: document.getElementById("activity-loader"),
|
||||
loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement,
|
||||
loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement,
|
||||
refreshButton: document.getElementById("activity-refresh") as HTMLButtonElement,
|
||||
filterArea: document.getElementById("activity-filter-area"),
|
||||
searchOptionsHeader: document.getElementById("activity-search-options-header"),
|
||||
searchBox: document.getElementById("activity-search") as HTMLInputElement,
|
||||
recordCounter: document.getElementById("activity-record-counter"),
|
||||
totalEndpoint: "/activity/count",
|
||||
getPageEndpoint: "/activity",
|
||||
itemsPerPage: 20,
|
||||
maxItemsLoadedForSearch: 200,
|
||||
appendNewItems: (resp: paginatedDTO) => {
|
||||
let ordering: string[] = this._search.ordering;
|
||||
for (let act of ((resp as ActivitiesDTO).activities || [])) {
|
||||
this.activities[act.id] = new Activity(act);
|
||||
ordering.push(act.id);
|
||||
}
|
||||
this._search.setOrdering(ordering, this._c.defaultSortField, this.ascending);
|
||||
},
|
||||
replaceWithNewItems: (resp: paginatedDTO) => {
|
||||
// FIXME: Implement updates to existing elements, rather than just wiping each time.
|
||||
|
||||
// Remove existing items
|
||||
for (let id of Object.keys(this.activities)) {
|
||||
delete this.activities[id];
|
||||
}
|
||||
// And wipe their ordering
|
||||
this._search.setOrdering([], this._c.defaultSortField, this.ascending);
|
||||
this._c.appendNewItems(resp);
|
||||
},
|
||||
defaultSortField: ACTIVITY_DEFAULT_SORT_FIELD,
|
||||
defaultSortAscending: ACTIVITY_DEFAULT_SORT_ASCENDING,
|
||||
pageLoadCallback: (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// FIXME: Remove!
|
||||
(window as any).act = this;
|
||||
|
||||
this._container = document.getElementById("activity-card-list")
|
||||
document.addEventListener("activity-reload", this.reload);
|
||||
|
||||
let searchConfig: SearchConfiguration = {
|
||||
filterArea: this._c.filterArea,
|
||||
// Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
|
||||
// sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._c.searchOptionsHeader,
|
||||
notFoundPanel: document.getElementById("activity-not-found"),
|
||||
notFoundLocallyText: document.getElementById("activity-no-local-results"),
|
||||
search: this._c.searchBox,
|
||||
clearSearchButtonSelector: ".activity-search-clear",
|
||||
serverSearchButtonSelector: ".activity-search-server",
|
||||
queries: queries(),
|
||||
setVisibility: null,
|
||||
filterList: document.getElementById("activity-filter-list"),
|
||||
// notFoundCallback: this._notFoundCallback,
|
||||
onSearchCallback: null,
|
||||
searchServer: null,
|
||||
clearServerSearch: null,
|
||||
}
|
||||
|
||||
this.initSearch(searchConfig);
|
||||
|
||||
this.ascending = this._c.defaultSortAscending;
|
||||
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
|
||||
}
|
||||
|
||||
reload = () => {
|
||||
this._lastLoad = Date.now();
|
||||
this._lastPage = false;
|
||||
this._loadMoreButton.textContent = window.lang.strings("loadMore");
|
||||
this._loadMoreButton.disabled = false;
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
this._loadAllButton.disabled = false;
|
||||
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
|
||||
// this._page = 0;
|
||||
let limit = 10;
|
||||
if (this._page != 0) {
|
||||
limit *= this._page+1;
|
||||
};
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": limit,
|
||||
"page": 0,
|
||||
"ascending": this.ascending
|
||||
}
|
||||
|
||||
_get("/activity/count", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
this.total = req.response["count"] as number;
|
||||
});
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasLoaded = true;
|
||||
// Allow refreshes every 15s
|
||||
this._refreshButton.disabled = true;
|
||||
setTimeout(() => this._refreshButton.disabled = false, 15000);
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
// FIXME: Don't destroy everything each reload!
|
||||
this._activities = {};
|
||||
this._ordering = [];
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
this._search.items = this._activities;
|
||||
this._search.ordering = this._ordering;
|
||||
|
||||
this.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch) {
|
||||
this._search.onSearchBoxChange(true);
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
} else {
|
||||
this.shown = this.loaded;
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
}, true);
|
||||
this._reload();
|
||||
}
|
||||
|
||||
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;
|
||||
this._loadMore(
|
||||
loadAll,
|
||||
(req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) return;
|
||||
if (callback) callback();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
get ascending(): boolean { return this._ascending; }
|
||||
set ascending(v: boolean) {
|
||||
this._ascending = v;
|
||||
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
|
||||
// FIXME?: We don't actually re-sort the list here, instead just use setOrdering to apply this.ascending before a reload.
|
||||
this._search.setOrdering(this._search.ordering, this._c.defaultSortField, this.ascending);
|
||||
if (this._hasLoaded) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
detectScroll = () => {
|
||||
if (!this._hasLoaded) return;
|
||||
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
|
||||
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
|
||||
// window.notifications.customSuccess("scroll", "Reached bottom.");
|
||||
// Wait .5s between loads
|
||||
if (this._lastLoad + 500 > Date.now()) return;
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private _prevResultCount = 0;
|
||||
|
||||
private _notFoundCallback = (notFound: boolean) => {
|
||||
/*private _notFoundCallback = (notFound: boolean) => {
|
||||
if (notFound) this._loadMoreButton.classList.add("unfocused");
|
||||
else this._loadMoreButton.classList.remove("unfocused");
|
||||
|
||||
@@ -703,53 +602,6 @@ export class activityList {
|
||||
this._keepSearchingButton.classList.add("unfocused");
|
||||
this._keepSearchingDescription.classList.add("unfocused");
|
||||
}
|
||||
};
|
||||
};*/
|
||||
|
||||
constructor() {
|
||||
this._activityList = document.getElementById("activity-card-list");
|
||||
document.addEventListener("activity-reload", this.reload);
|
||||
|
||||
let conf: SearchConfiguration = {
|
||||
filterArea: this._filterArea,
|
||||
sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._searchOptionsHeader,
|
||||
notFoundPanel: this._notFoundPanel,
|
||||
search: this._searchBox,
|
||||
clearSearchButtonSelector: ".activity-search-clear",
|
||||
queries: this._queries,
|
||||
setVisibility: this.setVisibility,
|
||||
filterList: document.getElementById("activity-filter-list"),
|
||||
// notFoundCallback: this._notFoundCallback,
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
|
||||
this.shown = visibleCount;
|
||||
|
||||
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
|
||||
else this._loadAllButton.classList.add("unfocused");
|
||||
|
||||
if (visibleCount < 10 || loadAll) {
|
||||
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
|
||||
}
|
||||
this._prevResultCount = visibleCount;
|
||||
}
|
||||
}
|
||||
this._search = new Search(conf);
|
||||
this._search.generateFilterList();
|
||||
|
||||
this._hasLoaded = false;
|
||||
this.ascending = false;
|
||||
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
|
||||
|
||||
this._loadMoreButton.onclick = () => this.loadMore();
|
||||
this._loadAllButton.onclick = () => {
|
||||
addLoader(this._loadAllButton, true);
|
||||
this.loadMore(() => {}, true);
|
||||
};
|
||||
/* this._keepSearchingButton.onclick = () => {
|
||||
addLoader(this._keepSearchingButton, true);
|
||||
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
||||
}; */
|
||||
this._refreshButton.onclick = this.reload;
|
||||
|
||||
window.onscroll = this.detectScroll;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { _get, _post } from "./common.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
export class Captcha {
|
||||
isPWR = false;
|
||||
enabled = true;
|
||||
@@ -51,7 +53,7 @@ export class Captcha {
|
||||
this.captchaID = this.isPWR ? this.code : req.response["id"];
|
||||
// the Math.random() appearance below is used for PWRs, since they don't have a unique captchaID. The parameter is ignored by the server, but tells the browser to reload the image.
|
||||
document.getElementById("captcha-img").innerHTML = `
|
||||
<img class="w-full" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${this.code}/${this.isPWR ? Math.random() : this.captchaID}${this.isPWR ? "?pwr=true" : ""}"></img>
|
||||
<img class="w-full" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf(window.pages.Form))}/captcha/img/${this.code}/${this.isPWR ? Math.random() : this.captchaID}${this.isPWR ? "?pwr=true" : ""}"></img>
|
||||
`;
|
||||
this.input.value = "";
|
||||
}
|
||||
|
||||
@@ -306,3 +306,20 @@ export function unicodeB64Encode(s: string): string {
|
||||
const bin = String.fromCodePoint(...encoded);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
// Only allow running a function every n milliseconds.
|
||||
// Source: Clément Prévost at https://stackoverflow.com/questions/27078285/simple-throttle-in-javascript
|
||||
// function foo<T>(bar: T): T {
|
||||
export function throttle (callback: () => void, limitMilliseconds: number): () => void {
|
||||
var waiting = false; // Initially, we're not waiting
|
||||
return function () { // We return a throttled function
|
||||
if (!waiting) { // If we're not waiting
|
||||
callback.apply(this, arguments); // Execute users function
|
||||
waiting = true; // Prevent future invocations
|
||||
setTimeout(function () { // After a period of time
|
||||
waiting = false; // And allow future invocations
|
||||
}, limitMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,7 @@ class DOMInvite implements Invite {
|
||||
get code(): string { return this._code; }
|
||||
set code(code: string) {
|
||||
this._code = code;
|
||||
let codeLink = window.location.href;
|
||||
for (let split of ["#", "?"]) {
|
||||
codeLink = codeLink.split(split)[0];
|
||||
}
|
||||
if (codeLink.slice(-1) != "/") { codeLink += "/"; }
|
||||
this._codeLink = codeLink + window.pages.Form + "/" + code;
|
||||
this._codeLink = window.pages.Base + window.pages.Form + "/" + code;
|
||||
const linkEl = this._codeArea.querySelector("a") as HTMLAnchorElement;
|
||||
if (this.label == "") {
|
||||
linkEl.textContent = code.replace(/-/g, '-');
|
||||
|
||||
513
ts/modules/list.ts
Normal file
513
ts/modules/list.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { _get, _post, addLoader, removeLoader, throttle } from "./common";
|
||||
import { Search, SearchConfiguration } from "./search";
|
||||
import "@af-utils/scrollend-polyfill";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
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(null, false);
|
||||
this._c.loadAllButton.onclick = () => {
|
||||
addLoader(this._c.loadAllButton, true);
|
||||
this.loadMore(null, 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(null, 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.log(`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;
|
||||
}
|
||||
|
||||
// Removes all elements, and reloads the first page.
|
||||
// FIXME: Share more code between reload and loadMore, and go over the logic, it's messy.
|
||||
public abstract reload: () => void;
|
||||
protected _reload = (
|
||||
callback?: (req: XMLHttpRequest) => void
|
||||
) => {
|
||||
this._lastLoad = Date.now();
|
||||
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;
|
||||
}
|
||||
|
||||
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
||||
params.limit = limit;
|
||||
params.page = 0;
|
||||
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 (callback) callback(req);
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasLoaded = true;
|
||||
// Allow refreshes every 15s
|
||||
this._c.refreshButton.disabled = true;
|
||||
setTimeout(() => this._c.refreshButton.disabled = false, 15000);
|
||||
|
||||
let resp = req.response as paginatedDTO;
|
||||
|
||||
this.lastPage = resp.last_page;
|
||||
|
||||
this._c.replaceWithNewItems(resp);
|
||||
|
||||
this._counter.loaded = this._search.ordering.length;
|
||||
|
||||
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 (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
if (callback) callback(req);
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
|
||||
public abstract loadMore: (callback: () => void, loadAll: boolean) => void;
|
||||
protected _loadMore = (
|
||||
loadAll: boolean = false,
|
||||
callback?: (req: XMLHttpRequest) => void
|
||||
) => {
|
||||
this._lastLoad = Date.now();
|
||||
this._c.loadMoreButton.disabled = true;
|
||||
const timeout = setTimeout(() => {
|
||||
this._c.loadMoreButton.disabled = false;
|
||||
}, 1000);
|
||||
this._page += 1;
|
||||
|
||||
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
||||
params.limit = this._c.itemsPerPage;
|
||||
params.page = this._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 (callback) callback(req);
|
||||
return;
|
||||
}
|
||||
|
||||
let resp = req.response as 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);
|
||||
}
|
||||
|
||||
this.lastPage = resp.last_page;
|
||||
|
||||
this._c.appendNewItems(resp);
|
||||
|
||||
this._counter.loaded = this._search.ordering.length;
|
||||
|
||||
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 (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
||||
if (callback) callback(req);
|
||||
}, true)
|
||||
}
|
||||
|
||||
loadNItems = (n: number) => {
|
||||
const cb = () => {
|
||||
if (this._counter.loaded > n) return;
|
||||
this.loadMore(cb, false);
|
||||
}
|
||||
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(cb, false)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,23 @@ const dateParser = require("any-date-parser");
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
export enum QueryOperator {
|
||||
Greater = ">",
|
||||
Lower = "<",
|
||||
Equal = "="
|
||||
}
|
||||
|
||||
export function QueryOperatorToDateText(op: QueryOperator): string {
|
||||
switch (op) {
|
||||
case QueryOperator.Greater:
|
||||
return window.lang.strings("after");
|
||||
case QueryOperator.Lower:
|
||||
return window.lang.strings("before");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueryType {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -11,41 +28,292 @@ export interface QueryType {
|
||||
date: boolean;
|
||||
dependsOnElement?: string; // Format for querySelector
|
||||
show?: boolean;
|
||||
localOnly?: boolean // Indicates can't be performed server-side.
|
||||
}
|
||||
|
||||
export interface SearchConfiguration {
|
||||
filterArea: HTMLElement;
|
||||
sortingByButton: HTMLButtonElement;
|
||||
sortingByButton?: HTMLButtonElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
notFoundPanel: HTMLElement;
|
||||
notFoundLocallyText: HTMLElement;
|
||||
notFoundCallback?: (notFound: boolean) => void;
|
||||
filterList: HTMLElement;
|
||||
clearSearchButtonSelector: string;
|
||||
serverSearchButtonSelector: string;
|
||||
search: HTMLInputElement;
|
||||
queries: { [field: string]: QueryType };
|
||||
setVisibility: (items: string[], visible: boolean) => void;
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
|
||||
setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
|
||||
onSearchCallback: (newItems: boolean, loadAll: boolean) => void;
|
||||
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
|
||||
clearServerSearch: () => void;
|
||||
loadMore?: () => void;
|
||||
}
|
||||
|
||||
export interface 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;
|
||||
|
||||
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._value = value;
|
||||
this._card.classList.add("button", "~" + (this._value ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
|
||||
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._value = value.toLowerCase();
|
||||
this._card.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DateAttempt {
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
hour?: number;
|
||||
minute?: number
|
||||
}
|
||||
|
||||
export interface ParsedDate {
|
||||
attempt: DateAttempt;
|
||||
date: Date;
|
||||
text: string;
|
||||
};
|
||||
|
||||
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._value = value;
|
||||
this._card.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
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] {
|
||||
// FIXME: Validate this!
|
||||
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: ParsedDate = {
|
||||
text: valueString,
|
||||
// Used just to tell use what fields the user passed.
|
||||
attempt: dateParser.attempt(valueString),
|
||||
// note Date.fromString is also provided by dateParser.
|
||||
date: (Date as any).fromString(valueString) as Date
|
||||
};
|
||||
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||
if ("month" in out.attempt) out.attempt.month -= 1;
|
||||
let isValid = true;
|
||||
if ("invalid" in (out.date as any)) { 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 {
|
||||
matchesSearch: (query: string) => boolean;
|
||||
// FIXME: SearchableItem should really be ListItem or something, this isn't for search!
|
||||
asElement: () => HTMLElement;
|
||||
}
|
||||
|
||||
export const SearchableItemDataAttribute = "data-search-item";
|
||||
|
||||
export type SearchableItems = { [id: string]: SearchableItem };
|
||||
|
||||
export class Search {
|
||||
private _c: SearchConfiguration;
|
||||
private _sortField: string = "";
|
||||
private _ascending: boolean = true;
|
||||
private _ordering: string[] = [];
|
||||
private _items: { [id: string]: SearchableItem };
|
||||
inSearch: boolean;
|
||||
private _items: SearchableItems = {};
|
||||
// Search queries (filters)
|
||||
private _queries: Query[] = [];
|
||||
// Plain-text search terms
|
||||
private _searchTerms: string[] = [];
|
||||
inSearch: boolean = false;
|
||||
private _inServerSearch: boolean = false;
|
||||
get inServerSearch(): boolean { return this._inServerSearch; }
|
||||
set inServerSearch(v: boolean) {
|
||||
const previous = this._inServerSearch;
|
||||
this._inServerSearch = v;
|
||||
if (!v && previous != v) {
|
||||
this._c.clearServerSearch();
|
||||
}
|
||||
}
|
||||
|
||||
search = (query: String): string[] => {
|
||||
this._c.filterArea.textContent = "";
|
||||
// Intended to be set from the JS console, if true searches are timed.
|
||||
timeSearches: boolean = false;
|
||||
|
||||
private _serverSearchButtons: HTMLElement[];
|
||||
|
||||
static tokenizeSearch = (query: string): string[] => {
|
||||
query = query.toLowerCase();
|
||||
|
||||
let result: string[] = [...this._ordering];
|
||||
let words: string[] = [];
|
||||
|
||||
let quoteSymbol = ``;
|
||||
let queryStart = -1;
|
||||
let lastQuote = -1;
|
||||
@@ -75,174 +343,144 @@ export class Search {
|
||||
}
|
||||
}
|
||||
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
||||
console.log("pushed", words);
|
||||
queryStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
query = "";
|
||||
for (let word of words) {
|
||||
parseTokens = (tokens: string[]): [string[], Query[]] => {
|
||||
let queries: Query[] = [];
|
||||
let searchTerms: string[] = [];
|
||||
|
||||
for (let word of tokens) {
|
||||
// 1. Normal search text, no filters or anything
|
||||
if (!word.includes(":")) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
if (!u.matchesSearch(word)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
searchTerms.push(word);
|
||||
continue;
|
||||
}
|
||||
// 2. A filter query of some sort.
|
||||
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
|
||||
|
||||
if (!(split[0] in this._c.queries)) continue;
|
||||
|
||||
const queryFormat = this._c.queries[split[0]];
|
||||
|
||||
if (queryFormat.bool) {
|
||||
let isBool = false;
|
||||
let boolState = false;
|
||||
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
|
||||
isBool = true;
|
||||
boolState = true;
|
||||
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
|
||||
isBool = true;
|
||||
boolState = false;
|
||||
}
|
||||
if (isBool) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}</span>
|
||||
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
|
||||
`;
|
||||
let q: Query | null = null;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
if (queryFormat.bool) {
|
||||
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
|
||||
if (isBool) {
|
||||
q = new BoolQuery(queryFormat, boolState);
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
// console.log("is bool, state", boolState);
|
||||
// So removing elements doesn't affect us
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
// console.log("got", queryFormat.getter + ":", value);
|
||||
// Remove from result if not matching query
|
||||
if (!((value && boolState) || (!value && !boolState))) {
|
||||
// console.log("not matching, result is", result);
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue
|
||||
};
|
||||
queries.push(q);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
|
||||
`;
|
||||
q = new StringQuery(queryFormat, split[1]);
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
||||
this._c.search.value = this._c.search.value.replace(regex, "");
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
|
||||
if (!(value.includes(split[1]))) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
queries.push(q);
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
|
||||
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
|
||||
let unmodifiedValue = split[1];
|
||||
if (compareType != 2) {
|
||||
split[1] = split[1].substring(1);
|
||||
}
|
||||
if (compareType == 2) compareType = 0;
|
||||
|
||||
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
|
||||
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||
if ("month" in attempt) attempt.month -= 1;
|
||||
|
||||
let date: Date = (Date as any).fromString(split[1]) as Date;
|
||||
console.log("Read", attempt, "and", date);
|
||||
if ("invalid" in (date as any)) continue;
|
||||
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
|
||||
`;
|
||||
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
||||
if (!isDate) continue;
|
||||
q = new DateQuery(queryFormat, op, parsedDate);
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
q.onclick = () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
|
||||
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
||||
this._c.search.value = this._c.search.value.replace(regex, "");
|
||||
}
|
||||
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
}
|
||||
queries.push(q);
|
||||
continue;
|
||||
}
|
||||
// if (q != null) queries.push(q);
|
||||
}
|
||||
return [searchTerms, queries];
|
||||
}
|
||||
|
||||
// Returns a list of identifiers (used as keys in items, values in ordering).
|
||||
searchParsed = (searchTerms: string[], queries: Query[]): string[] => {
|
||||
let result: string[] = [...this._ordering];
|
||||
// If we didn't care about rendering the query cards, we could run this to (maybe) return early.
|
||||
// if (this.inServerSearch) {
|
||||
// let hasLocalOnlyQueries = false;
|
||||
// for (const q of queries) {
|
||||
// if (q.localOnly) {
|
||||
// hasLocalOnlyQueries = true;
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Normal searches can be evaluated by the server, so skip this if we've already ran one.
|
||||
if (!this.inServerSearch) {
|
||||
for (let term of searchTerms) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
const u = this.items[id];
|
||||
if (!u.matchesSearch(term)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let q of queries) {
|
||||
this._c.filterArea.appendChild(q.asElement());
|
||||
// Skip if this query has already been performed by the server.
|
||||
if (this.inServerSearch && !(q.localOnly)) continue;
|
||||
|
||||
let cachedResult = [...result];
|
||||
if (q.subject.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.subject.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.subject.date) {
|
||||
for (let id of cachedResult) {
|
||||
const u = this.items[id];
|
||||
// Getter here returns a unix timestamp rather than a date, so we can't use compareItem.
|
||||
const unixValue = q.getValueFromItem(u);
|
||||
if (unixValue == 0) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
continue;
|
||||
}
|
||||
let value = new Date(unixValue*1000);
|
||||
|
||||
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
|
||||
|
||||
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
|
||||
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
|
||||
const temp = new Date(date.valueOf());
|
||||
temp.setHours(value.getHours(), value.getMinutes());
|
||||
value = temp;
|
||||
console.log("just hours/minutes workaround, value set to", value);
|
||||
}
|
||||
|
||||
|
||||
let match = true;
|
||||
if (compareType == 0) {
|
||||
for (let pair of getterPairs) {
|
||||
if (pair[0] in attempt) {
|
||||
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (compareType == -1) {
|
||||
match = (value < date);
|
||||
} else if (compareType == 1) {
|
||||
match = (value > date);
|
||||
}
|
||||
if (!match) {
|
||||
if (!q.compare(value)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
@@ -250,11 +488,35 @@ export class Search {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Returns a list of identifiers (used as keys in items, values in ordering).
|
||||
search = (query: string): string[] => {
|
||||
let timer = this.timeSearches ? performance.now() : null;
|
||||
this._c.filterArea.textContent = "";
|
||||
|
||||
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
|
||||
|
||||
let result = this.searchParsed(searchTerms, queries);
|
||||
|
||||
this._queries = queries;
|
||||
this._searchTerms = searchTerms;
|
||||
|
||||
if (this.timeSearches) {
|
||||
const totalTime = performance.now() - timer;
|
||||
console.log(`Search took ${totalTime}ms`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// postServerSearch performs local-only queries after a server search if necessary.
|
||||
postServerSearch = () => {
|
||||
this.searchParsed(this._searchTerms, this._queries);
|
||||
};
|
||||
|
||||
showHideSearchOptionsHeader = () => {
|
||||
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
|
||||
let sortingBy = false;
|
||||
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
|
||||
const hasFilters = this._c.filterArea.textContent != "";
|
||||
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
|
||||
if (sortingBy || hasFilters) {
|
||||
this._c.searchOptionsHeader.classList.remove("hidden");
|
||||
} else {
|
||||
@@ -262,16 +524,26 @@ export class Search {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -all- elements.
|
||||
get items(): { [id: string]: SearchableItem } { return this._items; }
|
||||
set items(v: { [id: string]: SearchableItem }) {
|
||||
this._items = v;
|
||||
// set items(v: { [id: string]: SearchableItem }) {
|
||||
// this._items = v;
|
||||
// }
|
||||
|
||||
// The order of -all- elements (even those hidden), by their identifier.
|
||||
get ordering(): string[] { return this._ordering; }
|
||||
// Specifically dis-allow setting ordering itself, so that setOrdering is used instead (for the field and ascending params).
|
||||
// set ordering(v: string[]) { this._ordering = v; }
|
||||
setOrdering = (v: string[], field: string, ascending: boolean) => {
|
||||
this._ordering = v;
|
||||
this._sortField = field;
|
||||
this._ascending = ascending;
|
||||
}
|
||||
|
||||
get ordering(): string[] { return this._ordering; }
|
||||
set ordering(v: string[]) { this._ordering = v; }
|
||||
get sortField(): string { return this._sortField; }
|
||||
get ascending(): boolean { return this._ascending; }
|
||||
|
||||
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
|
||||
onSearchBoxChange = (newItems: boolean = false, appendedItems: boolean = false, loadAll: boolean = false) => {
|
||||
const query = this._c.search.value;
|
||||
if (!query) {
|
||||
this.inSearch = false;
|
||||
@@ -279,15 +551,37 @@ export class Search {
|
||||
this.inSearch = true;
|
||||
}
|
||||
const results = this.search(query);
|
||||
this._c.setVisibility(results, true);
|
||||
this._c.onSearchCallback(results.length, newItems, loadAll);
|
||||
this._c.setVisibility(results, true, appendedItems);
|
||||
this._c.onSearchCallback(newItems, loadAll);
|
||||
if (this.inSearch) {
|
||||
if (this.inServerSearch) {
|
||||
this._serverSearchButtons.forEach((v: HTMLElement) => {
|
||||
v.classList.add("@low");
|
||||
v.classList.remove("@high");
|
||||
});
|
||||
} else {
|
||||
this._serverSearchButtons.forEach((v: HTMLElement) => {
|
||||
v.classList.add("@high");
|
||||
v.classList.remove("@low");
|
||||
});
|
||||
}
|
||||
}
|
||||
this.showHideSearchOptionsHeader();
|
||||
if (results.length == 0) {
|
||||
this.setNotFoundPanelVisibility(results.length == 0);
|
||||
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
|
||||
}
|
||||
|
||||
setNotFoundPanelVisibility = (visible: boolean) => {
|
||||
if (this._inServerSearch || !this.inSearch) {
|
||||
this._c.notFoundLocallyText.classList.add("unfocused");
|
||||
} else if (this.inSearch) {
|
||||
this._c.notFoundLocallyText.classList.remove("unfocused");
|
||||
}
|
||||
if (visible) {
|
||||
this._c.notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
|
||||
}
|
||||
|
||||
fillInFilter = (name: string, value: string, offset?: number) => {
|
||||
@@ -299,9 +593,8 @@ export class Search {
|
||||
this._c.search.setSelectionRange(newPos, newPos);
|
||||
this._c.search.oninput(null as any);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// FIXME: Make XQuery classes less specifically for in-progress searches, and include this code for making info button things.
|
||||
generateFilterList = () => {
|
||||
// Generate filter buttons
|
||||
for (let queryName of Object.keys(this._c.queries)) {
|
||||
@@ -376,17 +669,59 @@ export class Search {
|
||||
}
|
||||
}
|
||||
|
||||
onServerSearch = () => {
|
||||
const newServerSearch = !this.inServerSearch;
|
||||
this.inServerSearch = true;
|
||||
this._c.searchServer(this.serverSearchParams(this._searchTerms, this._queries), newServerSearch);
|
||||
}
|
||||
|
||||
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
|
||||
let req: ServerSearchReqDTO = {
|
||||
searchTerms: searchTerms,
|
||||
queries: [], // queries.map((q: Query) => q.asDTO()) won't work as localOnly queries return null
|
||||
limit: -1,
|
||||
page: 0,
|
||||
sortByField: this.sortField,
|
||||
ascending: this.ascending
|
||||
};
|
||||
for (const q of queries) {
|
||||
const dto = q.asDTO();
|
||||
if (dto !== null) req.queries.push(dto);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
setServerSearchButtonsDisabled = (disabled: boolean) => {
|
||||
this._serverSearchButtons.forEach((v: HTMLButtonElement) => v.disabled = disabled);
|
||||
}
|
||||
|
||||
constructor(c: SearchConfiguration) {
|
||||
this._c = c;
|
||||
|
||||
this._c.search.oninput = () => this.onSearchBoxChange();
|
||||
this._c.search.oninput = () => {
|
||||
this.inServerSearch = false;
|
||||
this.onSearchBoxChange();
|
||||
}
|
||||
this._c.search.addEventListener("keyup", (ev: KeyboardEvent) => {
|
||||
if (ev.key == "Enter") {
|
||||
this.onServerSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
|
||||
for (let b of clearSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this._c.search.value = "";
|
||||
this.inServerSearch = false;
|
||||
this.onSearchBoxChange();
|
||||
});
|
||||
}
|
||||
|
||||
this._serverSearchButtons = Array.from(document.querySelectorAll(this._c.serverSearchButtonSelector)) as Array<HTMLSpanElement>;
|
||||
for (let b of this._serverSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this.onServerSearch();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +639,7 @@ export class settingsList {
|
||||
}
|
||||
|
||||
private _showPanel = (name: string) => {
|
||||
console.log("showing", name);
|
||||
// console.log("showing", name);
|
||||
for (let n in this._sections) {
|
||||
if (n == name) {
|
||||
this._sections[name].visible = true;
|
||||
@@ -944,8 +944,13 @@ export class settingsList {
|
||||
|
||||
let firstVisibleSection = "";
|
||||
for (let section of this._settings.sections) {
|
||||
|
||||
let dependencyCard = this._sections[section.section].asElement().querySelector(".settings-dependency-message");
|
||||
// Section might be disabled at build-time (like Updates), or deprecated and so not appear.
|
||||
if (!(section.section in this._sections)) {
|
||||
// console.log(`Couldn't find section "${section.section}"`);
|
||||
continue
|
||||
}
|
||||
const sectionElement = this._sections[section.section].asElement();
|
||||
let dependencyCard = sectionElement.querySelector(".settings-dependency-message");
|
||||
if (dependencyCard) dependencyCard.remove();
|
||||
dependencyCard = null;
|
||||
let dependencyList = null;
|
||||
@@ -964,10 +969,13 @@ export class settingsList {
|
||||
matchedSection = true;
|
||||
}
|
||||
}
|
||||
const sectionElement = this._sections[section.section].asElement();
|
||||
for (let setting of section.settings) {
|
||||
if (setting.type == "note") continue;
|
||||
const element = sectionElement.querySelector(`div[data-name="${setting.setting}"]`) as HTMLElement;
|
||||
// Again, setting might be disabled at build-time (if we have such a mechanism) or deprecated (like the old duplicate 'url_base's)
|
||||
if (element == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we match the whole section, don't bother searching settings.
|
||||
if (matchedSection) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { PageManager, Page } from "../modules/pages.js";
|
||||
|
||||
declare var window: GlobalWindow;
|
||||
|
||||
export interface Tab {
|
||||
page: Page;
|
||||
tabEl: HTMLDivElement;
|
||||
@@ -26,7 +24,7 @@ export class Tabs implements Tabs {
|
||||
});
|
||||
}
|
||||
|
||||
addTab = (tabID: string, url: string, preFunc = () => void {}, postFunc = () => void {},) => {
|
||||
addTab = (tabID: string, url: string, preFunc = () => void {}, postFunc = () => void {}, unloadFunc = () => void {}) => {
|
||||
let tab: Tab = {
|
||||
page: null,
|
||||
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
|
||||
@@ -52,6 +50,7 @@ export class Tabs implements Tabs {
|
||||
tab.buttonEl.classList.remove("active");
|
||||
tab.buttonEl.classList.remove("~urge");
|
||||
tab.tabEl.classList.add("unfocused");
|
||||
if (unloadFunc) unloadFunc();
|
||||
return true;
|
||||
},
|
||||
shouldSkip: () => false,
|
||||
|
||||
43
ts/setup.ts
43
ts/setup.ts
@@ -365,6 +365,33 @@ const checkTheme = () => {
|
||||
settings["ui"]["theme"].onchange = checkTheme;
|
||||
checkTheme();
|
||||
|
||||
const fixFullURL = (v: string): string => {
|
||||
if (!(v.startsWith("http://")) && !(v.startsWith("https://"))) {
|
||||
v = "http://" + v;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
const formatSubpath = (v: string): string => {
|
||||
if (v == "/") return "";
|
||||
if (v.charAt(-1) == "/") { v = v.slice(0, -1); }
|
||||
return v;
|
||||
}
|
||||
|
||||
const constructNewURLs = (): string[] => {
|
||||
let local = settings["ui"]["host"].value + ":" + settings["ui"]["port"].value;
|
||||
if (settings["ui"]["url_base"].value != "") {
|
||||
local += formatSubpath(settings["ui"]["url_base"].value);
|
||||
}
|
||||
local = fixFullURL(local);
|
||||
let remote = settings["ui"]["jfa_url"].value;
|
||||
if (remote == "") {
|
||||
return [local];
|
||||
}
|
||||
remote = fixFullURL(remote);
|
||||
return [local, remote];
|
||||
}
|
||||
|
||||
const restartButton = document.getElementById("restart") as HTMLSpanElement;
|
||||
const serialize = () => {
|
||||
toggleLoader(restartButton);
|
||||
@@ -409,12 +436,16 @@ const serialize = () => {
|
||||
}
|
||||
restartButton.parentElement.querySelector("span.back").classList.add("unfocused");
|
||||
restartButton.classList.add("unfocused");
|
||||
const refresh = document.getElementById("refresh") as HTMLSpanElement;
|
||||
refresh.classList.remove("unfocused");
|
||||
refresh.onclick = () => {
|
||||
let host = window.location.href.split("#")[0].split("?")[0] + settings["ui"]["url_base"].value;
|
||||
window.location.href = host;
|
||||
};
|
||||
const refreshURLs = constructNewURLs();
|
||||
const refreshButtons = [document.getElementById("refresh-internal") as HTMLAnchorElement, document.getElementById("refresh-external") as HTMLAnchorElement];
|
||||
["internal", "external"].forEach((urltype, i) => {
|
||||
const button = refreshButtons[i];
|
||||
button.classList.remove("unfocused");
|
||||
button.href = refreshURLs[i];
|
||||
button.innerHTML = `<span>${urltype.charAt(0).toUpperCase() + urltype.slice(1)}:</span><i class="italic underline">${button.href}</i>`;
|
||||
// skip external if it isn't set
|
||||
if (refreshURLs.length == 1) return;
|
||||
});
|
||||
}
|
||||
}, true, (req: XMLHttpRequest) => {
|
||||
if (req.status == 0) {
|
||||
|
||||
@@ -91,7 +91,7 @@ declare interface NotificationBox {
|
||||
|
||||
declare interface Tabs {
|
||||
current: string;
|
||||
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void) => void;
|
||||
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void, unloadFunc?: () => void) => void;
|
||||
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -150,10 +150,16 @@ interface inviteList {
|
||||
loadInviteURL: () => void;
|
||||
}
|
||||
|
||||
// Finally added to typescript, dont need this anymore.
|
||||
// declare interface SubmitEvent extends Event {
|
||||
// submitter: HTMLInputElement;
|
||||
// }
|
||||
interface paginatedDTO {
|
||||
last_page: boolean;
|
||||
}
|
||||
|
||||
interface PaginatedReqDTO {
|
||||
limit: number;
|
||||
page: number;
|
||||
sortByField: string;
|
||||
ascending: boolean;
|
||||
};
|
||||
|
||||
declare var config: Object;
|
||||
declare var modifiedConfig: Object;
|
||||
|
||||
10
ts/user.ts
10
ts/user.ts
@@ -303,14 +303,10 @@ class ReferralCard {
|
||||
get code(): string { return this._code; }
|
||||
set code(c: string) {
|
||||
this._code = c;
|
||||
|
||||
|
||||
|
||||
let u = new URL(window.location.href);
|
||||
let path = u.pathname;
|
||||
for (let split of ["account", "my"]) {
|
||||
path = path.split(split)[0];
|
||||
}
|
||||
if (path.slice(-1) != "/") { path += "/"; }
|
||||
path = path + window.pages.Form + "/" + this._code;
|
||||
const path = window.pages.Base + window.pages.Form + "/" + this._code;
|
||||
|
||||
u.pathname = path;
|
||||
u.hash = "";
|
||||
|
||||
499
usercache.go
Normal file
499
usercache.go
Normal file
@@ -0,0 +1,499 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
// FIXME: Transmit timezone in request maybe?
|
||||
return subject.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// 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).CompareUnix(a.LastActive) == int(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).CompareUnix(a.Expiry) == int(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user