From ef253de56b48df972eae0ee6a39c7cf44239258e Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 26 May 2025 16:06:41 +0100 Subject: [PATCH] search: add localOnly to web app queries, fix string+bool queries localOnly: true in a queryType means it won't be sent to the server, but will be evaluated by the web app on the returned search results. --- activitysort.go | 13 ++++-- ts/modules/activity.ts | 3 +- ts/modules/search.ts | 90 +++++++++++++++++++++++++++++++----------- usercache.go | 30 ++++++++++++-- 4 files changed, 104 insertions(+), 32 deletions(-) diff --git a/activitysort.go b/activitysort.go index 8a020f6..d7aab59 100644 --- a/activitysort.go +++ b/activitysort.go @@ -43,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 { @@ -221,10 +220,18 @@ func matchReferrerAsQuery(jf *mediabrowser.MediaBrowser, query *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() { + if act.Type != ActivityCreation || act.SourceType != ActivityUser || !act.SourceIsUser() { return false, nil } - return strings.Contains(strings.ToLower(act.MustGetSourceUsername(jf)), strings.ToLower(q.Value.(string))), 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 } diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 7716f8c..c332d33 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -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"), diff --git a/ts/modules/search.ts b/ts/modules/search.ts index 0370ca0..46bb7fa 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -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,6 +379,8 @@ export class Search { } this._c.search.oninput((null as Event)); }; + queries.push(q); + continue; } } if (queryFormat.string) { @@ -384,6 +393,8 @@ export class Search { } this._c.search.oninput((null as Event)); } + queries.push(q); + continue; } if (queryFormat.date) { let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); @@ -398,37 +409,48 @@ 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're in a server search already, the results are (probably) already correct. + if (this.inServerSearch) { + let hasLocalOnlyQueries = false; + for (const q of queries) { + if (q.localOnly) { + hasLocalOnlyQueries = true; + break; + } + } + if (!hasLocalOnlyQueries) return result; + // Continue on if really necessary + } - 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) { @@ -466,7 +488,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; @@ -476,6 +509,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; @@ -642,12 +680,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; } diff --git a/usercache.go b/usercache.go index b9dc246..07e6868 100644 --- a/usercache.go +++ b/usercache.go @@ -306,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).CompareUnix(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).CompareUnix(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 {