mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-29 22:10:51 +01:00
Added the ability to sort direct search results by Most downloads (#1351)
Now that we know about the AA Downloads stats for search results (#1336), this PR allows sorting them locally by "Most downloads" without doing another AA search. Not needing to do an AA search for this is faster than the other sorts which do new AA searches asking for their results to be sorted a certain way. Also added the "Settings->Search Mode->Default Sort Order->Most downloads" option. Universal downloads do not allow for results sorting so I did not include this feature there. NOTE: While adding this feature I noticed that shelfmark only looks at the first page of AA search results. So switching between different sort orders may result in different sets of results with the same search term. Here's a couple of pics showing the new sort option: <img width="1186" height="759" alt="search-mode-sort-order-most-downloads" src="https://github.com/user-attachments/assets/c43fe2e7-84ba-48cd-a850-383f1af0d28f" /> <img width="1172" height="828" alt="direct-search-results-most-downloads" src="https://github.com/user-attachments/assets/4e240180-6a9d-4ce1-ab86-006b286570dd" /> Coded with llama.cpp, opencode and 🤖
This commit is contained in:
@@ -204,6 +204,7 @@ register_group(
|
||||
|
||||
# Direct mode sort options
|
||||
_AA_SORT_OPTIONS = [
|
||||
{"value": "", "label": "Most downloads"},
|
||||
{"value": "relevance", "label": "Most relevant"},
|
||||
{"value": "newest", "label": "Newest (publication year)"},
|
||||
{"value": "oldest", "label": "Oldest (publication year)"},
|
||||
@@ -483,7 +484,7 @@ def search_mode_settings() -> list[SettingsField]:
|
||||
label="Default Sort Order",
|
||||
description="Default sort order for search results.",
|
||||
options=_AA_SORT_OPTIONS,
|
||||
default="relevance",
|
||||
default="",
|
||||
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
||||
),
|
||||
CheckboxField(
|
||||
|
||||
+2
-2
@@ -1197,8 +1197,8 @@ def api_config() -> Response | tuple[Response, int]:
|
||||
"onboarding_complete": _get_onboarding_complete(),
|
||||
# Default sort orders
|
||||
"default_sort": app_config.get(
|
||||
"AA_DEFAULT_SORT", "relevance"
|
||||
), # For direct mode (Anna's Archive)
|
||||
"AA_DEFAULT_SORT", ""
|
||||
), # For direct mode (Anna's Archive) — empty means use local downloads sort
|
||||
"metadata_default_sort": get_provider_default_sort(
|
||||
metadata_ui_provider
|
||||
), # For universal mode
|
||||
|
||||
@@ -478,6 +478,7 @@ function App() {
|
||||
updateAdvancedFilters,
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
reSortByDownloads,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
searchFieldLabels,
|
||||
@@ -846,9 +847,7 @@ function App() {
|
||||
|
||||
// Determine the default sort based on search mode
|
||||
const defaultSort =
|
||||
cfg.search_mode === 'universal'
|
||||
? resolvedMetadataDefaultSort
|
||||
: cfg.default_sort || 'relevance';
|
||||
cfg.search_mode === 'universal' ? resolvedMetadataDefaultSort : (cfg.default_sort ?? '');
|
||||
|
||||
if (cfg?.supported_formats) {
|
||||
// Seeding the defaults must not undo filters a shared link already applied.
|
||||
@@ -951,6 +950,7 @@ function App() {
|
||||
contentTypeOverride?: ContentType;
|
||||
searchModeOverride?: SearchMode;
|
||||
providerOverride?: string;
|
||||
sort?: string;
|
||||
}) => {
|
||||
void refreshRequestPolicy();
|
||||
void handleSearch({
|
||||
@@ -960,6 +960,7 @@ function App() {
|
||||
contentTypeOverride: opts.contentTypeOverride,
|
||||
searchMode: opts.searchModeOverride,
|
||||
providerOverride: opts.providerOverride,
|
||||
sort: opts.sort,
|
||||
});
|
||||
},
|
||||
[refreshRequestPolicy, handleSearch, config],
|
||||
@@ -2027,7 +2028,7 @@ function App() {
|
||||
const urlHashDefaultSort =
|
||||
effectiveSearchMode === 'universal'
|
||||
? resolvedMetadataDefaultSort
|
||||
: config?.default_sort || 'relevance';
|
||||
: (config?.default_sort ?? '');
|
||||
|
||||
// Keep the URL hash fragment live as search state changes. Gated until any URL-driven
|
||||
// bootstrap has applied (or there was nothing to apply), so we don't clobber a shared
|
||||
@@ -2360,6 +2361,7 @@ function App() {
|
||||
fieldValues: request.fieldValues,
|
||||
searchModeOverride: effectiveSearchMode,
|
||||
providerOverride: request.providerOverride,
|
||||
sort: advancedFilters.sort,
|
||||
});
|
||||
}, [
|
||||
activeQueryOption,
|
||||
@@ -2583,11 +2585,19 @@ function App() {
|
||||
updateAdvancedFilters({ sort: request.appliedSort });
|
||||
}
|
||||
setActiveResultsSort(request.appliedSort);
|
||||
|
||||
// "Most downloads" is a client-side sort — just re-sort existing books
|
||||
if (request.appliedSort === 'downloads' && effectiveSearchMode === 'direct') {
|
||||
reSortByDownloads();
|
||||
return;
|
||||
}
|
||||
|
||||
runSearchWithPolicyRefresh({
|
||||
query: request.query,
|
||||
fieldValues: request.fieldValues,
|
||||
searchModeOverride: effectiveSearchMode,
|
||||
providerOverride: request.providerOverride,
|
||||
sort: request.appliedSort,
|
||||
});
|
||||
}}
|
||||
metadataSortOptions={resolvedMetadataSortOptions}
|
||||
|
||||
@@ -39,6 +39,7 @@ interface UrlSearchBootstrapMountProps {
|
||||
contentTypeOverride?: ContentType;
|
||||
searchModeOverride?: SearchMode;
|
||||
fieldValues?: Record<string, string | number | boolean>;
|
||||
sort?: string;
|
||||
}) => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
@@ -203,6 +204,7 @@ export const UrlSearchBootstrapMount = ({
|
||||
contentTypeOverride: urlContentTypeOverride,
|
||||
searchModeOverride: parsedSearchMode,
|
||||
fieldValues: urlFieldValues,
|
||||
sort: parsedParams?.advancedFilters?.sort,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Direct download mode sort options
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: '', label: 'Most relevant' },
|
||||
{ value: 'downloads', label: 'Most downloads' },
|
||||
{ value: 'relevance', label: 'Most relevant' },
|
||||
{ value: 'newest', label: 'Newest (publication year)' },
|
||||
{ value: 'oldest', label: 'Oldest (publication year)' },
|
||||
{ value: 'largest', label: 'Largest (filesize)' },
|
||||
|
||||
@@ -39,9 +39,11 @@ interface UseSearchReturn {
|
||||
contentTypeOverride?: ContentType;
|
||||
searchMode?: SearchMode;
|
||||
providerOverride?: string;
|
||||
sort?: string;
|
||||
}) => Promise<void>;
|
||||
handleResetSearch: (config: AppConfig | null) => void;
|
||||
resetSortFilter: () => void;
|
||||
reSortByDownloads: () => void;
|
||||
// Universal mode search field values
|
||||
searchFieldValues: SearchFieldValues;
|
||||
updateSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void;
|
||||
@@ -106,6 +108,24 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setAdvancedFilters((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// Sort books by downloads descending (no downloads go to the end)
|
||||
const sortBooksByDownloads = useCallback((bookList: Book[]): Book[] => {
|
||||
const sorted = bookList.toSorted((a, b) => {
|
||||
const aDownloads = a.downloads ?? 0;
|
||||
const bDownloads = b.downloads ?? 0;
|
||||
// Books with downloads come first, sorted by count descending
|
||||
if (aDownloads > 0 && bDownloads === 0) return -1;
|
||||
if (aDownloads === 0 && bDownloads > 0) return 1;
|
||||
return bDownloads - aDownloads;
|
||||
});
|
||||
return sorted;
|
||||
}, []);
|
||||
|
||||
// Re-sort current books by downloads descending (client-side only, no search)
|
||||
const reSortByDownloads = useCallback(() => {
|
||||
setBooks((prev) => sortBooksByDownloads(prev));
|
||||
}, [sortBooksByDownloads]);
|
||||
|
||||
const updateSearchFieldValue = useCallback(
|
||||
(key: string, value: string | number | boolean, label?: string) => {
|
||||
setSearchFieldValues((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -157,6 +177,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
contentTypeOverride,
|
||||
searchMode: searchModeOverride,
|
||||
providerOverride,
|
||||
sort,
|
||||
}: {
|
||||
query: string;
|
||||
config: AppConfig | null;
|
||||
@@ -164,6 +185,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
contentTypeOverride?: ContentType;
|
||||
searchMode?: SearchMode;
|
||||
providerOverride?: string;
|
||||
sort?: string;
|
||||
}) => {
|
||||
const effectiveContentType = contentTypeOverride ?? contentType;
|
||||
const searchMode = (searchModeOverride ?? config?.search_mode) || 'universal';
|
||||
@@ -177,7 +199,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
const hasFieldValues = Object.values(effectiveFieldValues).some(
|
||||
(v) => v !== '' && v !== false,
|
||||
);
|
||||
const sort = params.get('sort') || 'relevance';
|
||||
const sortOrder = params.get('sort') || 'relevance';
|
||||
|
||||
if (!searchQuery && !hasFieldValues) {
|
||||
setBooks([]);
|
||||
@@ -202,7 +224,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
const result = await searchMetadata(
|
||||
searchQuery,
|
||||
40,
|
||||
sort,
|
||||
sortOrder,
|
||||
effectiveFieldValues,
|
||||
1,
|
||||
effectiveContentType,
|
||||
@@ -221,7 +243,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
// Store params for loadMore
|
||||
lastSearchParamsRef.current = {
|
||||
query: searchQuery,
|
||||
sort,
|
||||
sort: sortOrder,
|
||||
fieldValues: effectiveFieldValues,
|
||||
providerOverride,
|
||||
contentType: effectiveContentType,
|
||||
@@ -255,7 +277,13 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
const results = await searchBooks(query);
|
||||
|
||||
if (results.length > 0) {
|
||||
setBooks(results);
|
||||
// When no explicit server-side sort is selected (empty or "downloads"),
|
||||
// sort locally by downloads descending so the most popular results
|
||||
// appear first.
|
||||
const effectiveSort = sort ?? advancedFilters.sort;
|
||||
const isDownloadsSort = !effectiveSort || effectiveSort === 'downloads';
|
||||
const sorted = isDownloadsSort ? sortBooksByDownloads(results) : results;
|
||||
setBooks(sorted);
|
||||
} else {
|
||||
showToast('No results found', 'error');
|
||||
}
|
||||
@@ -270,7 +298,14 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[showToast, searchFieldValues, handleSearchError, contentType],
|
||||
[
|
||||
showToast,
|
||||
searchFieldValues,
|
||||
handleSearchError,
|
||||
contentType,
|
||||
advancedFilters,
|
||||
sortBooksByDownloads,
|
||||
],
|
||||
);
|
||||
|
||||
const handleResetSearch = useCallback(
|
||||
@@ -367,6 +402,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
resetSortFilter,
|
||||
reSortByDownloads,
|
||||
// Universal mode search field values
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
|
||||
@@ -47,8 +47,11 @@ export const buildSearchQuery = ({
|
||||
if (content) queryParts.push(`content=${encodeURIComponent(content)}`);
|
||||
formats.forEach((format) => queryParts.push(`format=${encodeURIComponent(format)}`));
|
||||
|
||||
if (advancedFilters.sort) {
|
||||
queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`);
|
||||
// "downloads" is a client-side-only sort — send empty string so the server
|
||||
// doesn't receive an invalid sort parameter.
|
||||
const serverSort = advancedFilters.sort === 'downloads' ? '' : advancedFilters.sort;
|
||||
if (serverSort) {
|
||||
queryParts.push(`sort=${encodeURIComponent(serverSort)}`);
|
||||
}
|
||||
|
||||
return queryParts.join('&');
|
||||
|
||||
Reference in New Issue
Block a user