mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-27 13:10:20 +01:00
feat(search): add a configurable default content type (#1371)
Closes #1018. Worth correcting the issue first: the search tab is not hardcoded. `useContentTypePreferences` has persisted the user's choice to localStorage since #564, so a browser that has picked a tab already keeps it. What is missing is the other half the issue asks for, a default for a browser that has stored nothing, and a per-user override. `DEFAULT_CONTENT_TYPE` is a user overridable select next to `BOOK_LANGUAGE`, so it follows the same path: global value in Settings, per-user value in Search Preferences, resolved with `user_id` in `/api/config`. The frontend uses it only when this browser has no stored choice, which is captured before the existing effect writes one, so nothing changes for anyone who has already picked a tab. The resolution is a pure function in `utils/contentTypePreference.ts` rather than logic inside the hook, since vitest here has no jsdom and the existing tests cover resolvers like `resolveDefaultLanguageCodes` the same way. One small move in `App.tsx`: the `config` state was declared below the hook that now reads it, so it moved above it. ## Verification - `tests/core/test_config_api.py`: the payload carries `default_content_type` and reads it with the user's id. - `tests/core/test_admin_users_api.py`: the key appears in the per-user search preferences list. - `src/frontend/src/tests/contentTypePreference.test.ts`: a stored tab wins, combined mode survives, the server default applies when nothing is stored, and an unrecognised value falls back to ebook. - Python suite (3163) and frontend suite (201) green, plus ruff, ruff format, basedpyright, vulture, tsc, oxlint, oxfmt and the production build.
This commit is contained in:
@@ -314,6 +314,7 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a
|
||||
|----------|-------------|------|---------|
|
||||
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `universal` |
|
||||
| `BOOK_LANGUAGE` | Default language filter for searches. Users can override this for their own account. | string (comma-separated) | `en` |
|
||||
| `DEFAULT_CONTENT_TYPE` | Which tab the search page opens on. Users can override this for their own account, and a browser that has already picked a tab keeps its choice. | string (choice) | `ebook` |
|
||||
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
|
||||
| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` |
|
||||
| `SHOW_COMBINED_SELECTOR` | Show the option to search for and download both a book and audiobook together. | boolean | `true` |
|
||||
@@ -346,6 +347,16 @@ Default language filter for searches. Users can override this for their own acco
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** `en`
|
||||
|
||||
#### `DEFAULT_CONTENT_TYPE`
|
||||
|
||||
**Default Content Type**
|
||||
|
||||
Which tab the search page opens on. Users can override this for their own account, and a browser that has already picked a tab keeps its choice.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `ebook`
|
||||
- **Options:** `ebook` (Ebook), `audiobook` (Audiobook)
|
||||
|
||||
#### `AA_DEFAULT_SORT`
|
||||
|
||||
**Default Sort Order**
|
||||
|
||||
@@ -479,6 +479,20 @@ def search_mode_settings() -> list[SettingsField]:
|
||||
default=["en"],
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="DEFAULT_CONTENT_TYPE",
|
||||
label="Default Content Type",
|
||||
description=(
|
||||
"Which tab the search page opens on. Users can override this for their "
|
||||
"own account, and a browser that has already picked a tab keeps its choice."
|
||||
),
|
||||
options=[
|
||||
{"value": "ebook", "label": "Ebook"},
|
||||
{"value": "audiobook", "label": "Audiobook"},
|
||||
],
|
||||
default="ebook",
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="AA_DEFAULT_SORT",
|
||||
label="Default Sort Order",
|
||||
|
||||
@@ -1248,6 +1248,9 @@ def api_config() -> Response | tuple[Response, int]:
|
||||
"release_version": RELEASE_VERSION,
|
||||
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
|
||||
"default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
|
||||
"default_content_type": app_config.get(
|
||||
"DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id
|
||||
),
|
||||
"supported_formats": app_config.SUPPORTED_FORMATS,
|
||||
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
|
||||
"search_mode": search_mode,
|
||||
|
||||
@@ -308,9 +308,13 @@ function App() {
|
||||
showToast,
|
||||
});
|
||||
|
||||
// Declared here because the content type default below reads from it
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
|
||||
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
|
||||
const { contentType, setContentType, combinedMode, setCombinedMode } =
|
||||
useContentTypePreferences();
|
||||
const { contentType, setContentType, combinedMode, setCombinedMode } = useContentTypePreferences(
|
||||
config?.default_content_type,
|
||||
);
|
||||
|
||||
const {
|
||||
policy: requestPolicy,
|
||||
@@ -612,7 +616,6 @@ function App() {
|
||||
// Combined mode state (ebook + audiobook in one transaction)
|
||||
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [metadataProviders, setMetadataProviders] = useState<MetadataProviderSummary[]>([]);
|
||||
const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState<string | null>(null);
|
||||
const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState<
|
||||
|
||||
@@ -2,31 +2,25 @@ import { useCallback, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { ContentType } from '../../types';
|
||||
import {
|
||||
CONTENT_TYPE_STORAGE_KEY,
|
||||
resolveContentTypePreference,
|
||||
type ContentTypePreference,
|
||||
} from '../../utils/contentTypePreference';
|
||||
import { useDependencyEffect } from '../useMountEffect';
|
||||
|
||||
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
|
||||
|
||||
interface ContentTypePreference {
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
}
|
||||
|
||||
const readInitialPreference = (): ContentTypePreference => {
|
||||
const readStoredPreference = (): string | null => {
|
||||
try {
|
||||
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
|
||||
if (saved === 'combined') {
|
||||
return { contentType: 'ebook', combinedMode: true };
|
||||
}
|
||||
if (saved === 'ebook' || saved === 'audiobook') {
|
||||
return { contentType: saved, combinedMode: false };
|
||||
}
|
||||
return localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing
|
||||
return null;
|
||||
}
|
||||
return { contentType: 'ebook', combinedMode: false };
|
||||
};
|
||||
|
||||
export const useContentTypePreferences = (): {
|
||||
export const useContentTypePreferences = (
|
||||
serverDefault?: ContentType | null,
|
||||
): {
|
||||
contentType: ContentType;
|
||||
setContentType: Dispatch<SetStateAction<ContentType>>;
|
||||
combinedMode: boolean;
|
||||
@@ -34,13 +28,16 @@ export const useContentTypePreferences = (): {
|
||||
} => {
|
||||
// Both values live in one state object so each setter can derive the other
|
||||
// from a pure updater instead of mirroring it into a ref during render.
|
||||
const [preference, setPreference] = useState<ContentTypePreference>(readInitialPreference);
|
||||
const { contentType, combinedMode } = preference;
|
||||
const [preference, setPreference] = useState<ContentTypePreference>(() =>
|
||||
resolveContentTypePreference(readStoredPreference(), null),
|
||||
);
|
||||
const { contentType, combinedMode, source } = preference;
|
||||
|
||||
const setContentType: Dispatch<SetStateAction<ContentType>> = useCallback((value) => {
|
||||
setPreference((current) => ({
|
||||
...current,
|
||||
contentType: typeof value === 'function' ? value(current.contentType) : value,
|
||||
source: 'chosen',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -48,16 +45,34 @@ export const useContentTypePreferences = (): {
|
||||
setPreference((current) => ({
|
||||
...current,
|
||||
combinedMode: typeof value === 'function' ? value(current.combinedMode) : value,
|
||||
source: 'chosen',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// The config arrives after the first render, so the configured default is applied here.
|
||||
// It only lands while nothing has chosen a tab, which leaves a deep-linked content type
|
||||
// and a stored choice both untouched, and it does not count as a choice itself.
|
||||
useDependencyEffect(() => {
|
||||
if (!serverDefault) {
|
||||
return;
|
||||
}
|
||||
setPreference((current) =>
|
||||
current.source === 'unset' ? resolveContentTypePreference(null, serverDefault) : current,
|
||||
);
|
||||
}, [serverDefault]);
|
||||
|
||||
// Persist only what the user actually chose. Writing on every load would store a default
|
||||
// nobody picked, which is what stopped the configured default from ever being seen.
|
||||
useDependencyEffect(() => {
|
||||
if (source !== 'chosen') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType);
|
||||
} catch {
|
||||
// localStorage may be unavailable in private browsing
|
||||
}
|
||||
}, [contentType, combinedMode]);
|
||||
}, [contentType, combinedMode, source]);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveContentTypePreference } from '../utils/contentTypePreference';
|
||||
|
||||
describe('resolveContentTypePreference', () => {
|
||||
it('keeps the tab this browser chose, over any configured default', () => {
|
||||
expect(resolveContentTypePreference('audiobook', 'ebook')).toEqual({
|
||||
contentType: 'audiobook',
|
||||
combinedMode: false,
|
||||
source: 'chosen',
|
||||
});
|
||||
expect(resolveContentTypePreference('ebook', 'audiobook')).toEqual({
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
source: 'chosen',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps combined mode, which is stored as its own value', () => {
|
||||
expect(resolveContentTypePreference('combined', 'audiobook')).toEqual({
|
||||
contentType: 'ebook',
|
||||
combinedMode: true,
|
||||
source: 'chosen',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the configured default when this browser has chosen nothing', () => {
|
||||
expect(resolveContentTypePreference(null, 'audiobook')).toEqual({
|
||||
contentType: 'audiobook',
|
||||
combinedMode: false,
|
||||
source: 'unset',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a configured default open to being overridden', () => {
|
||||
// 'unset' is what lets a deep-linked content type and a later setting change win.
|
||||
expect(resolveContentTypePreference(null, 'audiobook').source).toBe('unset');
|
||||
expect(resolveContentTypePreference('audiobook', null).source).toBe('chosen');
|
||||
});
|
||||
|
||||
it('falls back to ebook with neither a stored choice nor a default', () => {
|
||||
expect(resolveContentTypePreference(null, null)).toEqual({
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
source: 'unset',
|
||||
});
|
||||
expect(resolveContentTypePreference(null, undefined).contentType).toBe('ebook');
|
||||
});
|
||||
|
||||
it('ignores a stored or configured value it does not recognise', () => {
|
||||
expect(resolveContentTypePreference('comic', 'audiobook')).toEqual({
|
||||
contentType: 'audiobook',
|
||||
combinedMode: false,
|
||||
source: 'unset',
|
||||
});
|
||||
expect(resolveContentTypePreference(null, 'comic')).toEqual({
|
||||
contentType: 'ebook',
|
||||
combinedMode: false,
|
||||
source: 'unset',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,7 @@ export interface AppConfig {
|
||||
release_version: string;
|
||||
book_languages: Language[];
|
||||
default_language: string[];
|
||||
default_content_type: ContentType;
|
||||
supported_formats: string[];
|
||||
supported_audiobook_formats: string[]; // Audiobook formats (m4b, mp3)
|
||||
search_mode: SearchMode;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ContentType } from '../types';
|
||||
|
||||
/**
|
||||
* Storage key for the tab this browser last chose.
|
||||
*
|
||||
* Bumped from `preferred-content-type`, which was written on every page load rather than
|
||||
* when the user picked a tab, so its value says nothing about what the user wanted. Reading
|
||||
* it would let a default that was never chosen outrank the configured one forever.
|
||||
*/
|
||||
export const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type.v2';
|
||||
|
||||
/** Where the current content type came from, which decides what may still override it. */
|
||||
export type ContentTypeSource =
|
||||
/** Nothing has chosen a tab, so a configured default may still apply. */
|
||||
| 'unset'
|
||||
/** This browser stored a choice, or something explicit set one. Defaults do not apply. */
|
||||
| 'chosen';
|
||||
|
||||
export interface ContentTypePreference {
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
source: ContentTypeSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tab to open on.
|
||||
*
|
||||
* A value this browser stored wins, because the user picked it. Otherwise the configured
|
||||
* default applies, and stays `unset` so it does not get mistaken for a choice: a deep link
|
||||
* still overrides it, and changing the setting reaches browsers that never picked a tab.
|
||||
*/
|
||||
export const resolveContentTypePreference = (
|
||||
stored: string | null,
|
||||
serverDefault: string | null | undefined,
|
||||
): ContentTypePreference => {
|
||||
if (stored === 'combined') {
|
||||
return { contentType: 'ebook', combinedMode: true, source: 'chosen' };
|
||||
}
|
||||
if (stored === 'ebook' || stored === 'audiobook') {
|
||||
return { contentType: stored, combinedMode: false, source: 'chosen' };
|
||||
}
|
||||
if (serverDefault === 'audiobook' || serverDefault === 'ebook') {
|
||||
return { contentType: serverDefault, combinedMode: false, source: 'unset' };
|
||||
}
|
||||
return { contentType: 'ebook', combinedMode: false, source: 'unset' };
|
||||
};
|
||||
@@ -1367,6 +1367,7 @@ class TestAdminSearchPreferences:
|
||||
assert data["keys"] == [
|
||||
"SEARCH_MODE",
|
||||
"BOOK_LANGUAGE",
|
||||
"DEFAULT_CONTENT_TYPE",
|
||||
"SHOW_COMBINED_SELECTOR",
|
||||
"FORCE_COMBINED_SEARCH",
|
||||
"METADATA_PROVIDER",
|
||||
|
||||
@@ -49,6 +49,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay",
|
||||
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES": ["book", "audiobook"],
|
||||
"BOOK_LANGUAGE": ["de", "en"],
|
||||
"DEFAULT_CONTENT_TYPE": "audiobook",
|
||||
"AUTO_OPEN_DOWNLOADS_SIDEBAR": False,
|
||||
"HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD": True,
|
||||
"AA_DEFAULT_SORT": "newest",
|
||||
@@ -77,6 +78,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
|
||||
assert data["default_release_source_audiobook"] == "audiobookbay"
|
||||
assert data["download_to_browser_content_types"] == ["book", "audiobook"]
|
||||
assert data["default_language"] == ["de", "en"]
|
||||
assert data["default_content_type"] == "audiobook"
|
||||
assert data["settings_enabled"] is True
|
||||
assert data["metadata_default_sort"] == "relevance"
|
||||
|
||||
@@ -84,6 +86,7 @@ def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
|
||||
assert ("SHOW_COMBINED_SELECTOR", 42) in calls
|
||||
assert ("DOWNLOAD_TO_BROWSER_CONTENT_TYPES", 42) in calls
|
||||
assert ("BOOK_LANGUAGE", 42) in calls
|
||||
assert ("DEFAULT_CONTENT_TYPE", 42) in calls
|
||||
|
||||
|
||||
def test_config_endpoint_falls_back_to_audiobook_metadata_provider(main_module, client):
|
||||
|
||||
Reference in New Issue
Block a user