From 732ce1bc5728ea8f7ae82cdb523a581d2c667937 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Wed, 21 May 2025 15:20:39 +0100 Subject: [PATCH] search: more server-search refinement fixed bugs, added extra text on "no results found" to suggest server searching, and conditionally disable the button based on search content and current sort. Activities page still broken. Also fixed up cache generation, only one should ever run now, as should sorting. Two time thresholds exist, one to trigger a re-sync but do it in the background (i.e. send the old one to the requester), and one to re-sync and wait for it. --- api-users.go | 16 +++- css/tooltip.css | 6 ++ html/admin.html | 40 +++++---- lang/admin/en-us.json | 2 + scripts/account-gen/main.go | 14 ++- ts/modules/accounts.ts | 27 +++--- ts/modules/activity.ts | 17 ++-- ts/modules/list.ts | 38 ++++---- ts/modules/search.ts | 171 ++++++++++++++++++++++-------------- usercache.go | 99 ++++++++++++++++----- 10 files changed, 281 insertions(+), 149 deletions(-) diff --git a/api-users.go b/api-users.go index a4e6214..bbaa9d7 100644 --- a/api-users.go +++ b/api-users.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "os" + "slices" "strings" "time" @@ -949,19 +950,26 @@ func (app *appContext) SearchUsers(gc *gin.Context) { } var resp getUsersDTO - userList, err := app.userCache.Gen(app, false) + userList, err := app.userCache.Gen(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 (req.SearchTerms != nil && len(req.SearchTerms) != 0) || (req.Queries != nil && len(req.Queries) != 0) { + if len(req.SearchTerms) != 0 || len(req.Queries) != 0 { filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries) } else { - filtered = userList + 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) } - app.userCache.Sort(filtered, req.SortByField, req.Ascending) startIndex := (req.Page * req.Limit) if startIndex < len(filtered) { diff --git a/css/tooltip.css b/css/tooltip.css index 85a289e..fc92a1d 100644 --- a/css/tooltip.css +++ b/css/tooltip.css @@ -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); } diff --git a/html/admin.html b/html/admin.html index 2a9a8fc..23e70e9 100644 --- a/html/admin.html +++ b/html/admin.html @@ -726,7 +726,10 @@
- + {{ .strings.searchAllRecords }}
@@ -810,20 +813,23 @@
-
- {{ .strings.noResultsFound }} - {{ .strings.keepSearchingDescription }} +
+ {{ .strings.noResultsFound }} + {{ .strings.noResultsFoundLocally }}
-
-
- - +
+ + +
@@ -843,7 +849,7 @@
- + {{ .strings.searchAllRecords }}
@@ -867,9 +873,9 @@
-
- {{ .strings.noResultsFound }} - {{ .strings.keepSearchingDescription }} +
+ {{ .strings.noResultsFound }} + {{ .strings.noResultsFoundLocally }}
-
- - +
+ + +
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index ff3dec1..d420ee8 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -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,7 @@ "filters": "Filters", "clickToRemoveFilter": "Click to remove this filter.", "clearSearch": "Clear search", + "searchAll": "Search all", "searchAllRecords": "Search all records (on server)", "actions": "Actions", "searchOptions": "Search Options", diff --git a/scripts/account-gen/main.go b/scripts/account-gen/main.go index 47faa6f..c61928f 100644 --- a/scripts/account-gen/main.go +++ b/scripts/account-gen/main.go @@ -14,8 +14,9 @@ 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"} - COUNT = 3000 + 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 ( @@ -101,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("Acc no %d: Failed to create user \"%s\" (%d): %+v\n", i, 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 { @@ -116,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("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) } } diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index a95b14f..46f50b3 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -9,6 +9,9 @@ import { PaginatedList } from "./list.js"; declare var window: GlobalWindow; +const USER_DEFAULT_SORT_FIELD = "name"; +const USER_DEFAULT_SORT_ASCENDING = true; + const dateParser = require("any-date-parser"); interface User { @@ -911,16 +914,14 @@ export class accountsList extends PaginatedList { loadMoreButton: document.getElementById("accounts-load-more") as HTMLButtonElement, loadAllButton: document.getElementById("accounts-load-all") as HTMLButtonElement, refreshButton: document.getElementById("accounts-refresh") as HTMLButtonElement, - keepSearchingDescription: document.getElementById("accounts-keep-searching-description"), - keepSearchingButton: document.getElementById("accounts-keep-searching"), filterArea: document.getElementById("accounts-filter-area"), searchOptionsHeader: document.getElementById("accounts-search-options-header"), searchBox: document.getElementById("accounts-search") as HTMLInputElement, - notFoundPanel: document.getElementById("accounts-not-found"), recordCounter: document.getElementById("accounts-record-counter"), totalEndpoint: "/users/count", getPageEndpoint: "/users", - limit: 4, + itemsPerPage: 40, + maxItemsLoadedForSearch: 200, newElementsFromPage: (resp: paginatedDTO) => { for (let u of ((resp as UsersDTO).users || [])) { if (u.id in this.users) { @@ -962,7 +963,8 @@ export class accountsList extends PaginatedList { this._search.ascending ); }, - defaultSortField: "name", + defaultSortField: USER_DEFAULT_SORT_FIELD, + defaultSortAscending: USER_DEFAULT_SORT_ASCENDING, pageLoadCallback: (req: XMLHttpRequest) => { if (req.readyState != 4) return; // FIXME: Error message @@ -975,7 +977,8 @@ export class accountsList extends PaginatedList { filterArea: this._c.filterArea, sortingByButton: this._sortingByButton, searchOptionsHeader: this._c.searchOptionsHeader, - notFoundPanel: this._c.notFoundPanel, + notFoundPanel: document.getElementById("accounts-not-found"), + notFoundLocallyText: document.getElementById("accounts-no-local-results"), filterList: document.getElementById("accounts-filter-list"), search: this._c.searchBox, queries: queries(), @@ -1188,7 +1191,7 @@ export class accountsList extends PaginatedList { // Start off sorting by username (this._c.defaultSortField) const defaultSort = () => { document.dispatchEvent(new CustomEvent("header-click", { detail: this._c.defaultSortField })); - this._columns[this._c.defaultSortField].ascending = true; + this._columns[this._c.defaultSortField].ascending = this._c.defaultSortAscending; this._columns[this._c.defaultSortField].hideIcon(); this._sortingByButton.parentElement.classList.add("hidden"); this._search.showHideSearchOptionsHeader(); @@ -1209,15 +1212,17 @@ export class accountsList extends PaginatedList { this._search.onSearchBoxChange(); } else { this.setVisibility(this._search.ordering, true); - this._c.notFoundPanel.classList.add("unfocused"); + this._search.setNotFoundPanelVisibility(false); + this._search.setServerSearchButtonsDisabled( + event.detail == this._c.defaultSortField && this._columns[event.detail].ascending == this._c.defaultSortAscending + ); } this._search.showHideSearchOptionsHeader(); }); defaultSort(); - this._search.showHideSearchOptionsHeader(); - this._search.generateFilterList(); + this._search.showHideSearchOptionsHeader(); this.registerURLListener(); } @@ -2168,7 +2173,7 @@ class Column { // Returns the inner HTML to show in the "Sorting By" button. get buttonContent() { - return `` + window.lang.strings("sortingBy") + ": " + `` + this._headerContent; + return `` + window.lang.strings("sortingBy") + ": " + `` + this._headerContent; } get ascending() { return this._ascending; } diff --git a/ts/modules/activity.ts b/ts/modules/activity.ts index 5780962..7f63017 100644 --- a/ts/modules/activity.ts +++ b/ts/modules/activity.ts @@ -6,6 +6,9 @@ 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; @@ -488,16 +491,14 @@ export class activityList extends PaginatedList { loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement, loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement, refreshButton: document.getElementById("activity-refresh") as HTMLButtonElement, - keepSearchingDescription: document.getElementById("activity-keep-searching-description"), - keepSearchingButton: document.getElementById("activity-keep-searching"), filterArea: document.getElementById("activity-filter-area"), searchOptionsHeader: document.getElementById("activity-search-options-header"), searchBox: document.getElementById("activity-search") as HTMLInputElement, - notFoundPanel: document.getElementById("activity-not-found"), recordCounter: document.getElementById("activity-record-counter"), totalEndpoint: "/activity/count", getPageEndpoint: "/activity", - limit: 10, + itemsPerPage: 20, + maxItemsLoadedForSearch: 200, newElementsFromPage: (resp: paginatedDTO) => { let ordering: string[] = this._search.ordering; for (let act of ((resp as ActivitiesDTO).activities || [])) { @@ -513,7 +514,8 @@ export class activityList extends PaginatedList { this._search.setOrdering([], this._c.defaultSortField, this.ascending); this._c.newElementsFromPage(resp); }, - defaultSortField: "time", + defaultSortField: ACTIVITY_DEFAULT_SORT_FIELD, + defaultSortAscending: ACTIVITY_DEFAULT_SORT_ASCENDING, pageLoadCallback: (req: XMLHttpRequest) => { if (req.readyState != 4) return; if (req.status != 200) { @@ -531,7 +533,8 @@ export class activityList extends PaginatedList { // 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: this._c.notFoundPanel, + 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", @@ -546,7 +549,7 @@ export class activityList extends PaginatedList { this.initSearch(searchConfig); - this.ascending = false; + this.ascending = this._c.defaultSortAscending; this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending); } diff --git a/ts/modules/list.ts b/ts/modules/list.ts index 0978317..414b550 100644 --- a/ts/modules/list.ts +++ b/ts/modules/list.ts @@ -76,19 +76,18 @@ export interface PaginatedListConfig { loadMoreButton: HTMLButtonElement; loadAllButton: HTMLButtonElement; refreshButton: HTMLButtonElement; - keepSearchingDescription: HTMLElement; - keepSearchingButton: HTMLElement; - notFoundPanel: HTMLElement; filterArea: HTMLElement; searchOptionsHeader: HTMLElement; searchBox: HTMLInputElement; recordCounter: HTMLElement; totalEndpoint: string; getPageEndpoint: string; - limit: number; + itemsPerPage: number; + maxItemsLoadedForSearch: number; newElementsFromPage: (resp: paginatedDTO) => void; updateExistingElementsFromPage: (resp: paginatedDTO) => void; defaultSortField: string; + defaultSortAscending: boolean; pageLoadCallback?: (req: XMLHttpRequest) => void; } @@ -109,13 +108,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; - if (this._search.inSearch) { - this._c.loadAllButton.classList.remove("unfocused"); - } + this._search.setServerSearchButtonsDisabled(false); + this._c.loadAllButton.classList.remove("unfocused"); } } @@ -160,9 +159,15 @@ export abstract class PaginatedList { // 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) + } + // FIXME: Figure out why this makes sense and make it clearer. - if ((visibleCount < this._c.limit && !this.lastPage) || loadAll) { + if ((visibleCount < this._c.itemsPerPage && this._counter.loaded < this._c.maxItemsLoadedForSearch && !this.lastPage) || loadAll) { if (!newItems || this._previousPageSize != visibleCount || (visibleCount == 0 && !this.lastPage) || @@ -183,6 +188,7 @@ export abstract class PaginatedList { if (previousServerSearch) previousServerSearch(params, newSearch); }; searchConfig.clearServerSearch = () => { + console.log("Clearing server search"); this._page = 0; this.reload(); } @@ -195,6 +201,7 @@ export abstract class PaginatedList { public abstract setVisibility: (elements: string[], visible: boolean) => void; // 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 @@ -206,7 +213,7 @@ export abstract class PaginatedList { 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.limit; + let limit = this._c.itemsPerPage; if (this._page != 0) { limit *= this._page+1; } @@ -216,7 +223,7 @@ export abstract class PaginatedList { params.page = 0; if (params.sortByField == "") { params.sortByField = this._c.defaultSortField; - params.ascending = true; + params.ascending = this._c.defaultSortAscending; } _post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => { @@ -246,8 +253,7 @@ export abstract class PaginatedList { } else { this._counter.shown = this._counter.loaded; this.setVisibility(this._search.ordering, true); - this._c.loadAllButton.classList.add("unfocused"); - this._c.notFoundPanel.classList.add("unfocused"); + // this._search.showHideNotFoundPanel(false); } if (this._c.pageLoadCallback) this._c.pageLoadCallback(req); if (callback) callback(req); @@ -268,11 +274,11 @@ export abstract class PaginatedList { this._page += 1; let params = this._search.inServerSearch ? this._searchParams : this.defaultParams(); - params.limit = this._c.limit; + params.limit = this._c.itemsPerPage; params.page = this._page; if (params.sortByField == "") { params.sortByField = this._c.defaultSortField; - params.ascending = true; + params.ascending = this._c.defaultSortAscending; } _post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => { @@ -304,7 +310,7 @@ export abstract class PaginatedList { this._search.onSearchBoxChange(true, loadAll); } else { this.setVisibility(this._search.ordering, true); - this._c.notFoundPanel.classList.add("unfocused"); + this._search.setNotFoundPanelVisibility(false); } if (this._c.pageLoadCallback) this._c.pageLoadCallback(req); if (callback) callback(req); diff --git a/ts/modules/search.ts b/ts/modules/search.ts index e93b653..92f99ec 100644 --- a/ts/modules/search.ts +++ b/ts/modules/search.ts @@ -35,6 +35,7 @@ export interface SearchConfiguration { sortingByButton?: HTMLButtonElement; searchOptionsHeader: HTMLElement; notFoundPanel: HTMLElement; + notFoundLocallyText: HTMLElement; notFoundCallback?: (notFound: boolean) => void; filterList: HTMLElement; clearSearchButtonSelector: string; @@ -91,6 +92,16 @@ export abstract class Query { 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)); + } } export class BoolQuery extends Query { @@ -278,30 +289,19 @@ export class Search { private _inServerSearch: boolean = false; get inServerSearch(): boolean { return this._inServerSearch; } set inServerSearch(v: boolean) { + const previous = this._inServerSearch; this._inServerSearch = v; - if (!v) { + if (!v && previous != v) { this._c.clearServerSearch(); } } private _serverSearchButtons: HTMLElement[]; - // Returns a list of identifiers (keys in items, values in ordering). - search = (query: String): string[] => { - this._c.filterArea.textContent = ""; - + static tokenizeSearch = (query: string): string[] => { query = query.toLowerCase(); - let result: string[] = [...this._ordering]; - // If we're in a server search already, the results are already correct. - if (this.inServerSearch) return result; - - let words: string[] = []; - let queries = []; - let searchTerms = []; - - let quoteSymbol = ``; let queryStart = -1; let lastQuote = -1; @@ -335,19 +335,17 @@ export class Search { } } } + 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(":")) { searchTerms.push(word); - let cachedResult = [...result]; - for (let id of cachedResult) { - const u = this.items[id]; - if (!u.matchesSearch(word)) { - result.splice(result.indexOf(id), 1); - } - } continue; } // 2. A filter query of some sort. @@ -369,21 +367,6 @@ export class Search { } this._c.search.oninput((null as Event)); }; - - this._c.filterArea.appendChild(q.asElement()); - - // 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); - // Remove from result if not matching query - if (!q.compare(value)) { - // console.log("not matching, result is", result); - result.splice(result.indexOf(id), 1); - } else { - } - } } } else if (queryFormat.string) { q = new StringQuery(queryFormat, split[1]); @@ -395,17 +378,6 @@ export class Search { } this._c.search.oninput((null as Event)); } - - this._c.filterArea.appendChild(q.asElement()); - - 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 (!q.compare(value)) { - result.splice(result.indexOf(id), 1); - } - } } else if (queryFormat.date) { let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); if (!isDate) continue; @@ -419,13 +391,61 @@ export class Search { this._c.search.oninput((null as Event)); } - - this._c.filterArea.appendChild(q.asElement()); + } + + if (q != null) queries.push(q); + } + return [searchTerms, queries]; + } - let cachedResult = [...result]; + // Returns a list of identifiers (used as keys in items, values in ordering). + search = (query: string): string[] => { + this._c.filterArea.textContent = ""; + + let result: string[] = [...this._ordering]; + // If we're in a server search already, the results are already correct. + if (this.inServerSearch) return result; + + 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); + } + } + } + for (let q of queries) { + this._c.filterArea.appendChild(q.asElement()); + let cachedResult = [...result]; + if (q.subject.bool) { for (let id of cachedResult) { const u = this.items[id]; - const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u); + // 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; @@ -437,12 +457,11 @@ export class Search { } } } - - if (q != null) queries.push(q); } this._queries = queries; this._searchTerms = searchTerms; + return result; } @@ -486,25 +505,37 @@ export class Search { const results = this.search(query); this._c.setVisibility(results, true); this._c.onSearchCallback(results.length, newItems, loadAll); - 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"); - }); + 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) { + console.log("showing not found panel"); this._c.notFoundPanel.classList.remove("unfocused"); } else { + console.log("hiding not found panel"); this._c.notFoundPanel.classList.add("unfocused"); - } - if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0); } fillInFilter = (name: string, value: string, offset?: number) => { @@ -610,6 +641,10 @@ export class Search { return req; } + setServerSearchButtonsDisabled = (disabled: boolean) => { + this._serverSearchButtons.forEach((v: HTMLButtonElement) => v.disabled = disabled); + } + constructor(c: SearchConfiguration) { // FIXME: Remove! if (c.search.id.includes("accounts")) { diff --git a/usercache.go b/usercache.go index 9b2e050..7864667 100644 --- a/usercache.go +++ b/usercache.go @@ -11,8 +11,12 @@ import ( const ( // FIXME: Follow mediabrowser, or make tuneable, or both - WEB_USER_CACHE_SYNC = 30 * time.Second - USER_DEFAULT_SORT_FIELD = "name" + // After cache is this old, re-sync, but do it in the background and return the old cache. + WEB_USER_CACHE_SYNC = 30 * time.Second + // After cache is this old, re-sync and wait for it and return the new cache. + WEB_USER_CACHE_WAIT_FOR_SYNC = 5 * time.Minute + USER_DEFAULT_SORT_FIELD = "name" + USER_DEFAULT_SORT_ASCENDING = true ) type UserCache struct { @@ -20,30 +24,67 @@ type UserCache struct { Ref []*respUser Sorted bool LastSync time.Time - Lock sync.Mutex + SyncLock sync.Mutex + Syncing bool + SortLock sync.Mutex + Sorting bool } +// FIXME: If shouldSync, sync in background and return old version. If shouldWaitForSync, wait for sync and return new one. +// FIXME: If locked, just wait for unlock and return someone elses work. func (c *UserCache) gen(app *appContext) error { - // FIXME: I don't like this. - if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) { + shouldWaitForSync := time.Now().After(c.LastSync.Add(WEB_USER_CACHE_WAIT_FOR_SYNC)) || c.Ref == nil || len(c.Ref) == 0 + shouldSync := time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) + + if !shouldSync { return nil } - c.Lock.Lock() - users, err := app.jf.GetUsers(false) - if err != 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 } - c.Cache = make([]respUser, len(users)) - for i, jfUser := range users { - c.Cache[i] = app.userSummary(jfUser) - } - c.Ref = make([]*respUser, len(c.Cache)) - for i := range c.Cache { - c.Ref[i] = &(c.Cache[i]) - } - c.Sorted = false - c.LastSync = time.Now() - c.Lock.Unlock() return nil } @@ -52,11 +93,21 @@ func (c *UserCache) Gen(app *appContext, sorted bool) ([]*respUser, error) { return nil, err } if sorted && !c.Sorted { - c.Lock.Lock() - // FIXME: Check we want ascending! - c.Sort(c.Ref, USER_DEFAULT_SORT_FIELD, true) - c.Sorted = true - c.Lock.Unlock() + 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 }