Compare commits

...

3 Commits

Author SHA1 Message Date
Harvey Tindall
699cbee240 search: fix "search all" button disabling logic, more
just a few more general fixes. Also changed the "Search all" button to
say "Search/sort all".
2025-05-26 17:28:09 +01:00
Harvey Tindall
ef253de56b 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.
2025-05-26 16:06:41 +01:00
Harvey Tindall
9715f90a48 search: fix server-side dates, add mentionedUsers, referrer, time
QueryDTO.Value being classed as "any" meant DateAttempts would be
unmarshaled as map[string]any, so a custom UnmarshalJSON checks the data
type and unmarshals into a DateAttempt if needed. mentionedUers,
referrer and time matching implemented for activity search. Also, fixed
multi-class queries (e.g. date -and- bool for last-active).
2025-05-26 15:09:40 +01:00
8 changed files with 314 additions and 89 deletions

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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"),

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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