mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
Compare commits
3 Commits
792296e3bc
...
699cbee240
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699cbee240 | ||
|
|
ef253de56b | ||
|
|
9715f90a48 |
146
activitysort.go
146
activitysort.go
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
@@ -41,7 +43,6 @@ func activityDTONameToField(field string) string {
|
||||
return "IP"
|
||||
}
|
||||
return "unknown"
|
||||
// Only these query types actually search the ActivityDTO data.
|
||||
}
|
||||
|
||||
func activityTypeGetterNameToType(getter string) ActivityType {
|
||||
@@ -70,17 +71,22 @@ func activityTypeGetterNameToType(getter string) ActivityType {
|
||||
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 {
|
||||
if query == nil {
|
||||
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 := query.And("Type")
|
||||
criterion := andField(query, "Type")
|
||||
if q.Operator != EqualOperator {
|
||||
panic(fmt.Errorf("impossible operator for activity type: %v", q.Operator))
|
||||
}
|
||||
@@ -93,12 +99,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.Query {
|
||||
}
|
||||
|
||||
fieldName := activityDTONameToField(q.Field)
|
||||
if fieldName == "unknown" {
|
||||
panic("FIXME: Support all the weird queries of the web UI!")
|
||||
// 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 := query.And(fieldName)
|
||||
criterion := andField(query, fieldName)
|
||||
|
||||
// FIXME: Deal with dates like we do in usercache.go
|
||||
switch q.Operator {
|
||||
case LesserOperator:
|
||||
query = criterion.Lt(q.Value)
|
||||
@@ -112,12 +119,13 @@ func (q QueryDTO) AsDBQuery(query *badgerhold.Query) *badgerhold.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 {
|
||||
subQuery := &badgerhold.Query{}
|
||||
var baseQuery *badgerhold.Query = nil
|
||||
// I don't believe you can just do Where("*"), so instead run for each field.
|
||||
for _, fieldName := range []string{"ID", "Type", "UserID", "Username", "SourceType", "Source", "SourceUsername", "InviteCode", "Value", "IP"} {
|
||||
// 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
|
||||
subQuery = subQuery.Or(criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
f := criterion.MatchFunc(func(ra *badgerhold.RecordAccess) (bool, error) {
|
||||
field := ra.Field()
|
||||
// _, ok := field.(string)
|
||||
// if !ok {
|
||||
@@ -130,8 +138,118 @@ func ActivityMatchesSearchAsDBBaseQuery(terms []string) *badgerhold.Query {
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}))
|
||||
})
|
||||
if baseQuery == nil {
|
||||
baseQuery = f
|
||||
} else {
|
||||
baseQuery = baseQuery.Or(f)
|
||||
}
|
||||
}
|
||||
|
||||
return subQuery
|
||||
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
|
||||
}
|
||||
|
||||
@@ -106,11 +106,19 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
if len(req.SearchTerms) != 0 {
|
||||
query = ActivityMatchesSearchAsDBBaseQuery(req.SearchTerms)
|
||||
} else {
|
||||
query = &badgerhold.Query{}
|
||||
query = nil
|
||||
}
|
||||
|
||||
for _, q := range req.Queries {
|
||||
query = q.AsDBQuery(query)
|
||||
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)
|
||||
@@ -132,28 +140,21 @@ func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,8 +137,8 @@
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
"searchAll": "Search all",
|
||||
"searchAllRecords": "Search all records (on server)",
|
||||
"searchAll": "Search/sort all",
|
||||
"searchAllRecords": "Search/sort all records (on server)",
|
||||
"actions": "Actions",
|
||||
"searchOptions": "Search Options",
|
||||
"matchText": "Match Text",
|
||||
|
||||
@@ -1188,7 +1188,7 @@ export class accountsList extends PaginatedList {
|
||||
const headerNames: string[] = ["username", "access-jfa", "email", "telegram", "matrix", "discord", "expiry", "last-active", "referrals"];
|
||||
const headerGetters: string[] = ["name", "accounts_admin", "email", "telegram", "matrix", "discord", "expiry", "last_active", "referrals_enabled"];
|
||||
for (let i = 0; i < headerNames.length; i++) {
|
||||
const header: HTMLTableHeaderCellElement = document.querySelector(".accounts-header-" + headerNames[i]) as HTMLTableHeaderCellElement;
|
||||
const header: HTMLTableCellElement = document.querySelector(".accounts-header-" + headerNames[i]) as HTMLTableCellElement;
|
||||
if (header !== null) {
|
||||
this._columns[headerGetters[i]] = new Column(header, headerGetters[i], Object.getOwnPropertyDescriptor(user.prototype, headerGetters[i]).get);
|
||||
}
|
||||
@@ -1219,10 +1219,9 @@ export class accountsList extends PaginatedList {
|
||||
} else {
|
||||
this.setVisibility(this._search.ordering, true);
|
||||
this._search.setNotFoundPanelVisibility(false);
|
||||
this._search.setServerSearchButtonsDisabled(
|
||||
event.detail == this._c.defaultSortField && this._columns[event.detail].ascending == this._c.defaultSortAscending
|
||||
);
|
||||
}
|
||||
this._search.inServerSearch = false;
|
||||
this.autoSetServerSearchButtonsDisabled();
|
||||
this._search.showHideSearchOptionsHeader();
|
||||
});
|
||||
|
||||
@@ -2116,14 +2115,14 @@ type Getter = () => GetterReturnType;
|
||||
// When list is refreshed, accountList calls method of the specific Column and re-orders accordingly.
|
||||
// Listen for broadcast event from others, check its not us by comparing the header className in the message, then hide the arrow icon
|
||||
class Column {
|
||||
private _header: HTMLTableHeaderCellElement;
|
||||
private _header: HTMLTableCellElement;
|
||||
private _name: string;
|
||||
private _headerContent: string;
|
||||
private _getter: Getter;
|
||||
private _ascending: boolean;
|
||||
private _active: boolean;
|
||||
|
||||
constructor(header: HTMLTableHeaderCellElement, name: string, getter: Getter) {
|
||||
constructor(header: HTMLTableCellElement, name: string, getter: Getter) {
|
||||
this._header = header;
|
||||
this._name = name;
|
||||
this._headerContent = this._header.textContent;
|
||||
|
||||
@@ -50,7 +50,8 @@ const queries = (): { [field: string]: QueryType } => { return {
|
||||
getter: "title",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
date: false,
|
||||
localOnly: true
|
||||
},
|
||||
"user": {
|
||||
name: window.lang.strings("usersMentioned"),
|
||||
|
||||
@@ -128,14 +128,13 @@ export abstract class PaginatedList {
|
||||
if (v) {
|
||||
this._c.loadAllButton.classList.add("unfocused");
|
||||
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
|
||||
this._search.setServerSearchButtonsDisabled(this._search.inServerSearch);
|
||||
this._c.loadMoreButton.disabled = true;
|
||||
} else {
|
||||
this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
|
||||
this._c.loadMoreButton.disabled = false;
|
||||
this._search.setServerSearchButtonsDisabled(false);
|
||||
this._c.loadAllButton.classList.remove("unfocused");
|
||||
}
|
||||
this.autoSetServerSearchButtonsDisabled();
|
||||
}
|
||||
|
||||
protected _previousVisibleItemCount = 0;
|
||||
@@ -171,17 +170,30 @@ export abstract class PaginatedList {
|
||||
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");
|
||||
|
||||
if (this._search.sortField == this._c.defaultSortField && this._search.ascending == this._c.defaultSortAscending) {
|
||||
this._search.setServerSearchButtonsDisabled(!this._search.inSearch)
|
||||
} else {
|
||||
this._search.setServerSearchButtonsDisabled(false)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -205,7 +217,7 @@ export abstract class PaginatedList {
|
||||
if (previousServerSearch) previousServerSearch(params, newSearch);
|
||||
};
|
||||
searchConfig.clearServerSearch = () => {
|
||||
console.log("Clearing server search");
|
||||
console.trace("Clearing server search");
|
||||
this._page = 0;
|
||||
this.reload();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface QueryType {
|
||||
date: boolean;
|
||||
dependsOnElement?: string; // Format for querySelector
|
||||
show?: boolean;
|
||||
localOnly?: boolean // Indicates can't be performed server-side.
|
||||
}
|
||||
|
||||
export interface SearchConfiguration {
|
||||
@@ -84,7 +85,8 @@ export abstract class Query {
|
||||
|
||||
public abstract compare(subjectValue: any): boolean;
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
asDTO(): QueryDTO | null {
|
||||
if (this.localOnly) return null;
|
||||
let out = {} as QueryDTO;
|
||||
out.field = this._subject.getter;
|
||||
out.operator = this._operator;
|
||||
@@ -100,6 +102,8 @@ export abstract class Query {
|
||||
compareItem(item: SearchableItem): boolean {
|
||||
return this.compare(this.getValueFromItem(item));
|
||||
}
|
||||
|
||||
get localOnly(): boolean { return this._subject.localOnly ? true : false; }
|
||||
}
|
||||
|
||||
export class BoolQuery extends Query {
|
||||
@@ -134,8 +138,9 @@ export class BoolQuery extends Query {
|
||||
return ((subjectBool && this._value) || (!subjectBool && !this._value))
|
||||
}
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
asDTO(): QueryDTO | null {
|
||||
let out = super.asDTO();
|
||||
if (out === null) return null;
|
||||
out.class = "bool";
|
||||
out.value = this._value;
|
||||
return out;
|
||||
@@ -159,8 +164,9 @@ export class StringQuery extends Query {
|
||||
return subjectString.toLowerCase().includes(this._value);
|
||||
}
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
asDTO(): QueryDTO | null {
|
||||
let out = super.asDTO();
|
||||
if (out === null) return null;
|
||||
out.class = "string";
|
||||
out.value = this._value;
|
||||
return out;
|
||||
@@ -259,8 +265,9 @@ export class DateQuery extends Query {
|
||||
return subjectDate > temp;
|
||||
}
|
||||
|
||||
asDTO(): QueryDTO {
|
||||
asDTO(): QueryDTO | null {
|
||||
let out = super.asDTO();
|
||||
if (out === null) return null;
|
||||
out.class = "date";
|
||||
out.value = this._value.attempt;
|
||||
return out;
|
||||
@@ -372,8 +379,11 @@ export class Search {
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
};
|
||||
queries.push(q);
|
||||
continue;
|
||||
}
|
||||
} else if (queryFormat.string) {
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
q = new StringQuery(queryFormat, split[1]);
|
||||
|
||||
q.onclick = () => {
|
||||
@@ -383,7 +393,10 @@ export class Search {
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
}
|
||||
} else if (queryFormat.date) {
|
||||
queries.push(q);
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
|
||||
if (!isDate) continue;
|
||||
q = new DateQuery(queryFormat, op, parsedDate);
|
||||
@@ -396,37 +409,46 @@ export class Search {
|
||||
|
||||
this._c.search.oninput((null as Event));
|
||||
}
|
||||
queries.push(q);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (q != null) queries.push(q);
|
||||
// if (q != null) queries.push(q);
|
||||
}
|
||||
return [searchTerms, queries];
|
||||
}
|
||||
|
||||
|
||||
// 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 = "";
|
||||
|
||||
searchParsed = (searchTerms: string[], queries: Query[]): string[] => {
|
||||
let result: string[] = [...this._ordering];
|
||||
// If we're in a server search already, the results are already correct.
|
||||
if (this.inServerSearch) return result;
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
|
||||
|
||||
query = "";
|
||||
|
||||
for (let term of searchTerms) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this.items[id];
|
||||
if (!u.matchesSearch(term)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
// 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];
|
||||
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) {
|
||||
@@ -464,7 +486,18 @@ 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;
|
||||
|
||||
@@ -474,6 +507,11 @@ export class Search {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// postServerSearch performs local-only queries after a server search if necessary.
|
||||
postServerSearch = () => {
|
||||
this.searchParsed(this._searchTerms, this._queries);
|
||||
};
|
||||
|
||||
showHideSearchOptionsHeader = () => {
|
||||
let sortingBy = false;
|
||||
@@ -640,12 +678,16 @@ export class Search {
|
||||
serverSearchParams = (searchTerms: string[], queries: Query[]): PaginatedReqDTO => {
|
||||
let req: ServerSearchReqDTO = {
|
||||
searchTerms: searchTerms,
|
||||
queries: queries.map((q: Query) => q.asDTO()),
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
72
usercache.go
72
usercache.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -241,13 +242,12 @@ type DateAttempt struct {
|
||||
Minute *int `json:"minute,omitempty"`
|
||||
}
|
||||
|
||||
// Compares a Unix timestamp.
|
||||
// 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 int64) int {
|
||||
subjectTime := time.Unix(subject, 0)
|
||||
yy, mo, dd := subjectTime.Date()
|
||||
hh, mm, _ := subjectTime.Clock()
|
||||
func (d DateAttempt) Compare(subject time.Time) int {
|
||||
yy, mo, dd := subject.Date()
|
||||
hh, mm, _ := subject.Clock()
|
||||
if d.Year != nil {
|
||||
yy = *d.Year
|
||||
}
|
||||
@@ -264,7 +264,13 @@ func (d DateAttempt) Compare(subject int64) int {
|
||||
if d.Minute != nil {
|
||||
mm = *d.Minute
|
||||
}
|
||||
return subjectTime.Compare(time.Date(yy, mo, dd, hh, mm, 0, 0, nil))
|
||||
// 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.
|
||||
@@ -300,16 +306,38 @@ func (q QueryDTO) AsFilter() Filter {
|
||||
return cmp.Compare(bool2int(a.NotifyThroughEmail), bool2int(q.Value.(bool))) == int(operator)
|
||||
}
|
||||
case "last_active":
|
||||
return func(a *respUser) bool {
|
||||
return q.Value.(DateAttempt).Compare(a.LastActive) == int(operator)
|
||||
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":
|
||||
return func(a *respUser) bool {
|
||||
return q.Value.(DateAttempt).Compare(a.Expiry) == int(operator)
|
||||
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 {
|
||||
@@ -397,6 +425,30 @@ type QueryDTO struct {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user