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.
This commit is contained in:
Harvey Tindall
2025-05-21 15:20:39 +01:00
parent 94e076401e
commit 732ce1bc57
10 changed files with 281 additions and 149 deletions
+12 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"slices"
"strings" "strings"
"time" "time"
@@ -949,19 +950,26 @@ func (app *appContext) SearchUsers(gc *gin.Context) {
} }
var resp getUsersDTO 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 { if err != nil {
app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err) app.err.Printf(lm.FailedGetUsers, lm.Jellyfin, err)
respond(500, "Couldn't get users", gc) respond(500, "Couldn't get users", gc)
return return
} }
var filtered []*respUser 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) filtered = app.userCache.Filter(userList, req.SearchTerms, req.Queries)
} else { } 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) startIndex := (req.Page * req.Limit)
if startIndex < len(filtered) { if startIndex < len(filtered) {
+6
View File
@@ -27,6 +27,12 @@
right: 0; right: 0;
} }
.tooltip.above .content {
bottom: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content { .tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.8);
} }
+25 -15
View File
@@ -726,7 +726,10 @@
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}"> <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> <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"> <div class="tooltip left">
<span class="button ~info @low center h-full accounts-search-server" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"><i class="ri-search-line"></i></span> <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> <span class="content sm">{{ .strings.searchAllRecords }}</span>
</div> </div>
<button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button> <button class="button ~info @low" id="accounts-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
@@ -810,20 +813,23 @@
</table> </table>
<div id="accounts-loader"></div> <div id="accounts-loader"></div>
<div class="unfocused h-[100%] my-3" id="accounts-not-found"> <div class="unfocused h-[100%] my-3" id="accounts-not-found">
<div class="flex flex-col h-[100%] justify-center items-center"> <div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic text-center">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="accounts-keep-searching-description">{{ .strings.keepSearchingDescription }}</span> <span class="text-sm font-light italic unfocused text-center" id="accounts-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row"> <div class="flex flex-row">
<button class="button ~neutral @low accounts-search-clear"> <button class="button ~neutral @low accounts-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i> <span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
</button> </button>
<button class="button ~neutral @low unfocused" id="accounts-keep-searching">{{ .strings.keepSearching }}</button>
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-center"> <div class="flex flex-row gap-2 m-2 justify-center">
<button class="button m-2 ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button> <button class="button ~neutral @low" id="accounts-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="accounts-load-all">{{ .strings.loadAll }}</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>
</div> </div>
@@ -843,7 +849,7 @@
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}"> <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> <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>
<div class="tooltip left"> <div class="tooltip left">
<span class="button ~info @low center h-full activity-search-server" aria-label="{{ .strings.searchAllRecords }}" text="{{ .strings.searchAllRecords }}"><i class="ri-search-line"></i></span> <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> <span class="content sm">{{ .strings.searchAllRecords }}</span>
</div> </div>
<button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button> <button class="button ~info @low" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
@@ -867,9 +873,9 @@
<div id="activity-card-list"></div> <div id="activity-card-list"></div>
<div id="activity-loader"></div> <div id="activity-loader"></div>
<div class="unfocused h-[100%] my-3" id="activity-not-found"> <div class="unfocused h-[100%] my-3" id="activity-not-found">
<div class="flex flex-col h-[100%] justify-center items-center"> <div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span> <span class="text-2xl font-medium italic mb-3 text-center">{{ .strings.noResultsFound }}</span>
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span> <span class="text-sm font-light italic unfocused text-center" id="activity-no-local-results">{{ .strings.noResultsFoundLocally }}</span>
<div class="flex flex-row"> <div class="flex flex-row">
<button class="button ~neutral @low activity-search-clear"> <button class="button ~neutral @low activity-search-clear">
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i> <span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
@@ -878,9 +884,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-center"> <div class="flex flex-row gap-2 m-2 justify-center">
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button> <button class="button ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</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> </div>
</div> </div>
+2
View File
@@ -58,6 +58,7 @@
"disabled": "Disabled", "disabled": "Disabled",
"sendPWR": "Send Password Reset", "sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found", "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", "keepSearching": "Keep Searching",
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.", "keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
"contactThrough": "Contact through:", "contactThrough": "Contact through:",
@@ -136,6 +137,7 @@
"filters": "Filters", "filters": "Filters",
"clickToRemoveFilter": "Click to remove this filter.", "clickToRemoveFilter": "Click to remove this filter.",
"clearSearch": "Clear search", "clearSearch": "Clear search",
"searchAll": "Search all",
"searchAllRecords": "Search all records (on server)", "searchAllRecords": "Search all records (on server)",
"actions": "Actions", "actions": "Actions",
"searchOptions": "Search Options", "searchOptions": "Search Options",
+10 -4
View File
@@ -14,8 +14,9 @@ import (
) )
var ( 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 = 3000 COUNT = 4000
DELAY = 1 * time.Millisecond
) )
const ( const (
@@ -101,11 +102,12 @@ func main() {
rand.Seed(time.Now().Unix()) rand.Seed(time.Now().Unix())
for i := 0; i < COUNT; i++ { 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) user, status, err := jf.NewUser(name, PASSWORD)
if (status != 200 && status != 201 && status != 204) || err != nil { 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 { if rand.Intn(100) > 65 {
@@ -116,13 +118,17 @@ func main() {
user.Policy.IsDisabled = true user.Policy.IsDisabled = true
} }
time.Sleep(DELAY / 4)
status, err = jf.SetPolicy(user.ID, user.Policy) status, err = jf.SetPolicy(user.ID, user.Policy)
if (status != 200 && status != 201 && status != 204) || err != nil { 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) log.Fatalf("Acc no %d: Failed to set policy for user \"%s\" (%d): %+v\n", i, name, status, err)
} }
if rand.Intn(100) > 20 { if rand.Intn(100) > 20 {
time.Sleep(DELAY / 4)
jfTemp.Authenticate(name, PASSWORD) jfTemp.Authenticate(name, PASSWORD)
} }
log.Printf("Acc %d done\n", i)
time.Sleep(DELAY / 4)
} }
} }
+16 -11
View File
@@ -9,6 +9,9 @@ import { PaginatedList } from "./list.js";
declare var window: GlobalWindow; declare var window: GlobalWindow;
const USER_DEFAULT_SORT_FIELD = "name";
const USER_DEFAULT_SORT_ASCENDING = true;
const dateParser = require("any-date-parser"); const dateParser = require("any-date-parser");
interface User { interface User {
@@ -911,16 +914,14 @@ export class accountsList extends PaginatedList {
loadMoreButton: document.getElementById("accounts-load-more") as HTMLButtonElement, loadMoreButton: document.getElementById("accounts-load-more") as HTMLButtonElement,
loadAllButton: document.getElementById("accounts-load-all") as HTMLButtonElement, loadAllButton: document.getElementById("accounts-load-all") as HTMLButtonElement,
refreshButton: document.getElementById("accounts-refresh") 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"), filterArea: document.getElementById("accounts-filter-area"),
searchOptionsHeader: document.getElementById("accounts-search-options-header"), searchOptionsHeader: document.getElementById("accounts-search-options-header"),
searchBox: document.getElementById("accounts-search") as HTMLInputElement, searchBox: document.getElementById("accounts-search") as HTMLInputElement,
notFoundPanel: document.getElementById("accounts-not-found"),
recordCounter: document.getElementById("accounts-record-counter"), recordCounter: document.getElementById("accounts-record-counter"),
totalEndpoint: "/users/count", totalEndpoint: "/users/count",
getPageEndpoint: "/users", getPageEndpoint: "/users",
limit: 4, itemsPerPage: 40,
maxItemsLoadedForSearch: 200,
newElementsFromPage: (resp: paginatedDTO) => { newElementsFromPage: (resp: paginatedDTO) => {
for (let u of ((resp as UsersDTO).users || [])) { for (let u of ((resp as UsersDTO).users || [])) {
if (u.id in this.users) { if (u.id in this.users) {
@@ -962,7 +963,8 @@ export class accountsList extends PaginatedList {
this._search.ascending this._search.ascending
); );
}, },
defaultSortField: "name", defaultSortField: USER_DEFAULT_SORT_FIELD,
defaultSortAscending: USER_DEFAULT_SORT_ASCENDING,
pageLoadCallback: (req: XMLHttpRequest) => { pageLoadCallback: (req: XMLHttpRequest) => {
if (req.readyState != 4) return; if (req.readyState != 4) return;
// FIXME: Error message // FIXME: Error message
@@ -975,7 +977,8 @@ export class accountsList extends PaginatedList {
filterArea: this._c.filterArea, filterArea: this._c.filterArea,
sortingByButton: this._sortingByButton, sortingByButton: this._sortingByButton,
searchOptionsHeader: this._c.searchOptionsHeader, 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"), filterList: document.getElementById("accounts-filter-list"),
search: this._c.searchBox, search: this._c.searchBox,
queries: queries(), queries: queries(),
@@ -1188,7 +1191,7 @@ export class accountsList extends PaginatedList {
// Start off sorting by username (this._c.defaultSortField) // Start off sorting by username (this._c.defaultSortField)
const defaultSort = () => { const defaultSort = () => {
document.dispatchEvent(new CustomEvent("header-click", { detail: this._c.defaultSortField })); 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._columns[this._c.defaultSortField].hideIcon();
this._sortingByButton.parentElement.classList.add("hidden"); this._sortingByButton.parentElement.classList.add("hidden");
this._search.showHideSearchOptionsHeader(); this._search.showHideSearchOptionsHeader();
@@ -1209,15 +1212,17 @@ export class accountsList extends PaginatedList {
this._search.onSearchBoxChange(); this._search.onSearchBoxChange();
} else { } else {
this.setVisibility(this._search.ordering, true); 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(); this._search.showHideSearchOptionsHeader();
}); });
defaultSort(); defaultSort();
this._search.showHideSearchOptionsHeader();
this._search.generateFilterList(); this._search.showHideSearchOptionsHeader();
this.registerURLListener(); this.registerURLListener();
} }
@@ -2168,7 +2173,7 @@ class Column {
// Returns the inner HTML to show in the "Sorting By" button. // Returns the inner HTML to show in the "Sorting By" button.
get buttonContent() { get buttonContent() {
return `<span class="font-bold">` + window.lang.strings("sortingBy") + ": " + `</span>` + this._headerContent; return `<i class="ri-arrow-${this.ascending ? "up" : "down"}-s-line mr-2"></i><span class="font-bold">` + window.lang.strings("sortingBy") + ": " + `</span>` + this._headerContent;
} }
get ascending() { return this._ascending; } get ascending() { return this._ascending; }
+10 -7
View File
@@ -6,6 +6,9 @@ import { PaginatedList } from "./list.js";
declare var window: GlobalWindow; declare var window: GlobalWindow;
const ACTIVITY_DEFAULT_SORT_FIELD = "time";
const ACTIVITY_DEFAULT_SORT_ASCENDING = false;
export interface activity { export interface activity {
id: string; id: string;
type: string; type: string;
@@ -488,16 +491,14 @@ export class activityList extends PaginatedList {
loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement, loadMoreButton: document.getElementById("activity-load-more") as HTMLButtonElement,
loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement, loadAllButton: document.getElementById("activity-load-all") as HTMLButtonElement,
refreshButton: document.getElementById("activity-refresh") 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"), filterArea: document.getElementById("activity-filter-area"),
searchOptionsHeader: document.getElementById("activity-search-options-header"), searchOptionsHeader: document.getElementById("activity-search-options-header"),
searchBox: document.getElementById("activity-search") as HTMLInputElement, searchBox: document.getElementById("activity-search") as HTMLInputElement,
notFoundPanel: document.getElementById("activity-not-found"),
recordCounter: document.getElementById("activity-record-counter"), recordCounter: document.getElementById("activity-record-counter"),
totalEndpoint: "/activity/count", totalEndpoint: "/activity/count",
getPageEndpoint: "/activity", getPageEndpoint: "/activity",
limit: 10, itemsPerPage: 20,
maxItemsLoadedForSearch: 200,
newElementsFromPage: (resp: paginatedDTO) => { newElementsFromPage: (resp: paginatedDTO) => {
let ordering: string[] = this._search.ordering; let ordering: string[] = this._search.ordering;
for (let act of ((resp as ActivitiesDTO).activities || [])) { 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._search.setOrdering([], this._c.defaultSortField, this.ascending);
this._c.newElementsFromPage(resp); this._c.newElementsFromPage(resp);
}, },
defaultSortField: "time", defaultSortField: ACTIVITY_DEFAULT_SORT_FIELD,
defaultSortAscending: ACTIVITY_DEFAULT_SORT_ASCENDING,
pageLoadCallback: (req: XMLHttpRequest) => { pageLoadCallback: (req: XMLHttpRequest) => {
if (req.readyState != 4) return; if (req.readyState != 4) return;
if (req.status != 200) { 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. // Exclude this: We only sort by date, and don't want to show a redundant header indicating so.
// sortingByButton: this._sortingByButton, // sortingByButton: this._sortingByButton,
searchOptionsHeader: this._c.searchOptionsHeader, 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, search: this._c.searchBox,
clearSearchButtonSelector: ".activity-search-clear", clearSearchButtonSelector: ".activity-search-clear",
serverSearchButtonSelector: ".activity-search-server", serverSearchButtonSelector: ".activity-search-server",
@@ -546,7 +549,7 @@ export class activityList extends PaginatedList {
this.initSearch(searchConfig); this.initSearch(searchConfig);
this.ascending = false; this.ascending = this._c.defaultSortAscending;
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending); this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
} }
+22 -16
View File
@@ -76,19 +76,18 @@ export interface PaginatedListConfig {
loadMoreButton: HTMLButtonElement; loadMoreButton: HTMLButtonElement;
loadAllButton: HTMLButtonElement; loadAllButton: HTMLButtonElement;
refreshButton: HTMLButtonElement; refreshButton: HTMLButtonElement;
keepSearchingDescription: HTMLElement;
keepSearchingButton: HTMLElement;
notFoundPanel: HTMLElement;
filterArea: HTMLElement; filterArea: HTMLElement;
searchOptionsHeader: HTMLElement; searchOptionsHeader: HTMLElement;
searchBox: HTMLInputElement; searchBox: HTMLInputElement;
recordCounter: HTMLElement; recordCounter: HTMLElement;
totalEndpoint: string; totalEndpoint: string;
getPageEndpoint: string; getPageEndpoint: string;
limit: number; itemsPerPage: number;
maxItemsLoadedForSearch: number;
newElementsFromPage: (resp: paginatedDTO) => void; newElementsFromPage: (resp: paginatedDTO) => void;
updateExistingElementsFromPage: (resp: paginatedDTO) => void; updateExistingElementsFromPage: (resp: paginatedDTO) => void;
defaultSortField: string; defaultSortField: string;
defaultSortAscending: boolean;
pageLoadCallback?: (req: XMLHttpRequest) => void; pageLoadCallback?: (req: XMLHttpRequest) => void;
} }
@@ -109,13 +108,13 @@ export abstract class PaginatedList {
if (v) { if (v) {
this._c.loadAllButton.classList.add("unfocused"); this._c.loadAllButton.classList.add("unfocused");
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults"); this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
this._search.setServerSearchButtonsDisabled(this._search.inServerSearch);
this._c.loadMoreButton.disabled = true; this._c.loadMoreButton.disabled = true;
} else { } else {
this._c.loadMoreButton.textContent = window.lang.strings("loadMore"); this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
this._c.loadMoreButton.disabled = false; this._c.loadMoreButton.disabled = false;
if (this._search.inSearch) { this._search.setServerSearchButtonsDisabled(false);
this._c.loadAllButton.classList.remove("unfocused"); 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"); // if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
// else this._c.loadAllButton.classList.add("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. // 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 || if (!newItems ||
this._previousPageSize != visibleCount || this._previousPageSize != visibleCount ||
(visibleCount == 0 && !this.lastPage) || (visibleCount == 0 && !this.lastPage) ||
@@ -183,6 +188,7 @@ export abstract class PaginatedList {
if (previousServerSearch) previousServerSearch(params, newSearch); if (previousServerSearch) previousServerSearch(params, newSearch);
}; };
searchConfig.clearServerSearch = () => { searchConfig.clearServerSearch = () => {
console.log("Clearing server search");
this._page = 0; this._page = 0;
this.reload(); this.reload();
} }
@@ -195,6 +201,7 @@ export abstract class PaginatedList {
public abstract setVisibility: (elements: string[], visible: boolean) => void; public abstract setVisibility: (elements: string[], visible: boolean) => void;
// Removes all elements, and reloads the first page. // 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; public abstract reload: () => void;
protected _reload = ( protected _reload = (
callback?: (req: XMLHttpRequest) => void callback?: (req: XMLHttpRequest) => void
@@ -206,7 +213,7 @@ export abstract class PaginatedList {
this._counter.getTotal(this._c.totalEndpoint); this._counter.getTotal(this._c.totalEndpoint);
// Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)). // 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) { if (this._page != 0) {
limit *= this._page+1; limit *= this._page+1;
} }
@@ -216,7 +223,7 @@ export abstract class PaginatedList {
params.page = 0; params.page = 0;
if (params.sortByField == "") { if (params.sortByField == "") {
params.sortByField = this._c.defaultSortField; params.sortByField = this._c.defaultSortField;
params.ascending = true; params.ascending = this._c.defaultSortAscending;
} }
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => { _post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
@@ -246,8 +253,7 @@ export abstract class PaginatedList {
} else { } else {
this._counter.shown = this._counter.loaded; this._counter.shown = this._counter.loaded;
this.setVisibility(this._search.ordering, true); this.setVisibility(this._search.ordering, true);
this._c.loadAllButton.classList.add("unfocused"); // this._search.showHideNotFoundPanel(false);
this._c.notFoundPanel.classList.add("unfocused");
} }
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req); if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req); if (callback) callback(req);
@@ -268,11 +274,11 @@ export abstract class PaginatedList {
this._page += 1; this._page += 1;
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams(); let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
params.limit = this._c.limit; params.limit = this._c.itemsPerPage;
params.page = this._page; params.page = this._page;
if (params.sortByField == "") { if (params.sortByField == "") {
params.sortByField = this._c.defaultSortField; params.sortByField = this._c.defaultSortField;
params.ascending = true; params.ascending = this._c.defaultSortAscending;
} }
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => { _post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
@@ -304,7 +310,7 @@ export abstract class PaginatedList {
this._search.onSearchBoxChange(true, loadAll); this._search.onSearchBoxChange(true, loadAll);
} else { } else {
this.setVisibility(this._search.ordering, true); 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 (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
if (callback) callback(req); if (callback) callback(req);
+103 -68
View File
@@ -35,6 +35,7 @@ export interface SearchConfiguration {
sortingByButton?: HTMLButtonElement; sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement; searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement; notFoundPanel: HTMLElement;
notFoundLocallyText: HTMLElement;
notFoundCallback?: (notFound: boolean) => void; notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement; filterList: HTMLElement;
clearSearchButtonSelector: string; clearSearchButtonSelector: string;
@@ -91,6 +92,16 @@ export abstract class Query {
out.operator = this._operator; out.operator = this._operator;
return out; 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 { export class BoolQuery extends Query {
@@ -278,30 +289,19 @@ export class Search {
private _inServerSearch: boolean = false; private _inServerSearch: boolean = false;
get inServerSearch(): boolean { return this._inServerSearch; } get inServerSearch(): boolean { return this._inServerSearch; }
set inServerSearch(v: boolean) { set inServerSearch(v: boolean) {
const previous = this._inServerSearch;
this._inServerSearch = v; this._inServerSearch = v;
if (!v) { if (!v && previous != v) {
this._c.clearServerSearch(); this._c.clearServerSearch();
} }
} }
private _serverSearchButtons: HTMLElement[]; private _serverSearchButtons: HTMLElement[];
// Returns a list of identifiers (keys in items, values in ordering). static tokenizeSearch = (query: string): string[] => {
search = (query: String): string[] => {
this._c.filterArea.textContent = "";
query = query.toLowerCase(); 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 words: string[] = [];
let queries = [];
let searchTerms = [];
let quoteSymbol = ``; let quoteSymbol = ``;
let queryStart = -1; let queryStart = -1;
let lastQuote = -1; let lastQuote = -1;
@@ -335,19 +335,17 @@ export class Search {
} }
} }
} }
return words;
}
query = ""; parseTokens = (tokens: string[]): [string[], Query[]] => {
for (let word of words) { let queries: Query[] = [];
let searchTerms: string[] = [];
for (let word of tokens) {
// 1. Normal search text, no filters or anything // 1. Normal search text, no filters or anything
if (!word.includes(":")) { if (!word.includes(":")) {
searchTerms.push(word); 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; continue;
} }
// 2. A filter query of some sort. // 2. A filter query of some sort.
@@ -369,21 +367,6 @@ export class Search {
} }
this._c.search.oninput((null as Event)); 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) { } else if (queryFormat.string) {
q = new StringQuery(queryFormat, split[1]); q = new StringQuery(queryFormat, split[1]);
@@ -395,17 +378,6 @@ export class Search {
} }
this._c.search.oninput((null as Event)); 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) { } else if (queryFormat.date) {
let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]); let [parsedDate, op, isDate] = DateQuery.paramsFromString(split[1]);
if (!isDate) continue; if (!isDate) continue;
@@ -419,13 +391,61 @@ export class Search {
this._c.search.oninput((null as Event)); 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) { for (let id of cachedResult) {
const u = this.items[id]; 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) { if (unixValue == 0) {
result.splice(result.indexOf(id), 1); result.splice(result.indexOf(id), 1);
continue; continue;
@@ -437,12 +457,11 @@ export class Search {
} }
} }
} }
if (q != null) queries.push(q);
} }
this._queries = queries; this._queries = queries;
this._searchTerms = searchTerms; this._searchTerms = searchTerms;
return result; return result;
} }
@@ -486,25 +505,37 @@ export class Search {
const results = this.search(query); const results = this.search(query);
this._c.setVisibility(results, true); this._c.setVisibility(results, true);
this._c.onSearchCallback(results.length, newItems, loadAll); this._c.onSearchCallback(results.length, newItems, loadAll);
if (this.inServerSearch) { if (this.inSearch) {
this._serverSearchButtons.forEach((v: HTMLElement) => { if (this.inServerSearch) {
v.classList.add("@low"); this._serverSearchButtons.forEach((v: HTMLElement) => {
v.classList.remove("@high"); v.classList.add("@low");
}); v.classList.remove("@high");
} else { });
this._serverSearchButtons.forEach((v: HTMLElement) => { } else {
v.classList.add("@high"); this._serverSearchButtons.forEach((v: HTMLElement) => {
v.classList.remove("@low"); v.classList.add("@high");
}); v.classList.remove("@low");
});
}
} }
this.showHideSearchOptionsHeader(); 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"); this._c.notFoundPanel.classList.remove("unfocused");
} else { } else {
console.log("hiding not found panel");
this._c.notFoundPanel.classList.add("unfocused"); this._c.notFoundPanel.classList.add("unfocused");
} }
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
} }
fillInFilter = (name: string, value: string, offset?: number) => { fillInFilter = (name: string, value: string, offset?: number) => {
@@ -610,6 +641,10 @@ export class Search {
return req; return req;
} }
setServerSearchButtonsDisabled = (disabled: boolean) => {
this._serverSearchButtons.forEach((v: HTMLButtonElement) => v.disabled = disabled);
}
constructor(c: SearchConfiguration) { constructor(c: SearchConfiguration) {
// FIXME: Remove! // FIXME: Remove!
if (c.search.id.includes("accounts")) { if (c.search.id.includes("accounts")) {
+75 -24
View File
@@ -11,8 +11,12 @@ import (
const ( const (
// FIXME: Follow mediabrowser, or make tuneable, or both // FIXME: Follow mediabrowser, or make tuneable, or both
WEB_USER_CACHE_SYNC = 30 * time.Second // After cache is this old, re-sync, but do it in the background and return the old cache.
USER_DEFAULT_SORT_FIELD = "name" 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 { type UserCache struct {
@@ -20,30 +24,67 @@ type UserCache struct {
Ref []*respUser Ref []*respUser
Sorted bool Sorted bool
LastSync time.Time 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 { func (c *UserCache) gen(app *appContext) error {
// FIXME: I don't like this. shouldWaitForSync := time.Now().After(c.LastSync.Add(WEB_USER_CACHE_WAIT_FOR_SYNC)) || c.Ref == nil || len(c.Ref) == 0
if !time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC)) { shouldSync := time.Now().After(c.LastSync.Add(WEB_USER_CACHE_SYNC))
if !shouldSync {
return nil return nil
} }
c.Lock.Lock()
users, err := app.jf.GetUsers(false) syncStatus := make(chan error)
if err != nil {
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 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 return nil
} }
@@ -52,11 +93,21 @@ func (c *UserCache) Gen(app *appContext, sorted bool) ([]*respUser, error) {
return nil, err return nil, err
} }
if sorted && !c.Sorted { if sorted && !c.Sorted {
c.Lock.Lock() c.SortLock.Lock()
// FIXME: Check we want ascending! alreadySorting := c.Sorting
c.Sort(c.Ref, USER_DEFAULT_SORT_FIELD, true) c.Sorting = true
c.Sorted = true c.SortLock.Unlock()
c.Lock.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 return c.Ref, nil
} }