mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 10:51:16 +01:00
Add content_type=combined URL parameter and FORCE_COMBINED_SEARCH user setting (#1058)
Adds support for `content_type=combined` in URL search parameters, letting users force combined-mode searches via a bookmarkable link rather than relying on the last-used preference from localStorage. The override is applied only in Universal mode and only when combined mode is actually available (universal enabled, `show_combined_selector` on, neither content type blocked by policy). Otherwise, it's silently ignored, consistent with how `content_type=ebook`/`audiobook` already behave outside Universal. Existing `content_type=ebook`/`audiobook` URLs now also force combined mode off, so the URL is authoritative regardless of prior preference. Also adds a per-user `FORCE_COMBINED_SEARCH` setting that locks combined mode on whenever it's available. URL `content_type=ebook`/`audiobook` overrides are also superseded by force-combined for the same reason: the search bar wouldn't let users switch back, so honoring the URL param would leave them in a state they couldn't escape from.
This commit is contained in:
@@ -766,7 +766,8 @@ function App() {
|
||||
(cfg.show_combined_selector ?? true) &&
|
||||
getDefaultMode('ebook') !== 'blocked' &&
|
||||
getDefaultMode('audiobook') !== 'blocked';
|
||||
const nextEffectiveCombinedMode = combinedMode && nextCombinedModeAllowed;
|
||||
const nextEffectiveCombinedMode =
|
||||
nextCombinedModeAllowed && (combinedMode || cfg.force_combined_search);
|
||||
const activeConfiguredProvider =
|
||||
nextEffectiveCombinedMode && metadataProviderState.configured_provider_combined
|
||||
? metadataProviderState.configured_provider_combined
|
||||
@@ -864,7 +865,8 @@ function App() {
|
||||
const audiobookMode = getDefaultMode('audiobook');
|
||||
return ebookMode !== 'blocked' && audiobookMode !== 'blocked';
|
||||
}, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]);
|
||||
const effectiveCombinedMode = combinedMode && combinedModeAllowed;
|
||||
const combinedModeLocked = combinedModeAllowed && config?.force_combined_search === true;
|
||||
const effectiveCombinedMode = combinedModeAllowed && (combinedMode || combinedModeLocked);
|
||||
const effectiveCombinedState = effectiveCombinedMode ? combinedState : null;
|
||||
|
||||
const defaultMetadataProviderForContentType =
|
||||
@@ -2418,6 +2420,7 @@ function App() {
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={effectiveCombinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={effectiveActiveQueryTarget}
|
||||
@@ -2499,6 +2502,7 @@ function App() {
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={effectiveCombinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||
activeQueryField={activeQueryField}
|
||||
searchMode={effectiveSearchMode}
|
||||
@@ -2774,10 +2778,13 @@ function App() {
|
||||
parsedParams={parsedParams}
|
||||
config={config}
|
||||
contentType={contentType}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeAllowed={combinedModeAllowed}
|
||||
advancedFilters={advancedFilters}
|
||||
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
|
||||
resolvedMetadataSortOptions={resolvedMetadataSortOptions}
|
||||
setContentType={setContentType}
|
||||
setCombinedMode={setCombinedMode}
|
||||
setSearchInput={setSearchInput}
|
||||
setAdvancedFilters={setAdvancedFilters}
|
||||
setShowAdvanced={setShowAdvanced}
|
||||
|
||||
@@ -62,6 +62,7 @@ interface HeaderProps {
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
combinedModeLocked?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
@@ -127,6 +128,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode,
|
||||
combinedModeLocked,
|
||||
onCombinedModeChange,
|
||||
queryTargets = EMPTY_QUERY_TARGETS,
|
||||
activeQueryTarget = 'general',
|
||||
@@ -721,6 +723,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={onCombinedModeChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface SearchBarProps {
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
combinedModeLocked?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
@@ -181,6 +182,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode = false,
|
||||
combinedModeLocked = false,
|
||||
onCombinedModeChange,
|
||||
queryTargets = EMPTY_QUERY_TARGETS,
|
||||
activeQueryTarget = 'general',
|
||||
@@ -798,11 +800,19 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
{combinedModeLocked ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ interface SearchSectionProps {
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
combinedModeLocked?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
activeQueryField?: MetadataSearchField | null;
|
||||
searchMode: SearchMode;
|
||||
@@ -64,6 +65,7 @@ export const SearchSection = ({
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode,
|
||||
combinedModeLocked,
|
||||
onCombinedModeChange,
|
||||
activeQueryField,
|
||||
searchMode,
|
||||
@@ -105,6 +107,7 @@ export const SearchSection = ({
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={onCombinedModeChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
|
||||
@@ -12,10 +12,13 @@ interface UrlSearchBootstrapMountProps {
|
||||
parsedParams: ParsedUrlSearch;
|
||||
config: AppConfig;
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
combinedModeAllowed: boolean;
|
||||
advancedFilters: AdvancedFilterState;
|
||||
resolvedMetadataDefaultSort: string;
|
||||
resolvedMetadataSortOptions: SortOption[];
|
||||
setContentType: (value: ContentType) => void;
|
||||
setCombinedMode: (value: boolean) => void;
|
||||
setSearchInput: (value: string) => void;
|
||||
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
|
||||
setShowAdvanced: (value: boolean) => void;
|
||||
@@ -32,10 +35,13 @@ export const UrlSearchBootstrapMount = ({
|
||||
parsedParams,
|
||||
config,
|
||||
contentType,
|
||||
combinedMode,
|
||||
combinedModeAllowed,
|
||||
advancedFilters,
|
||||
resolvedMetadataDefaultSort,
|
||||
resolvedMetadataSortOptions,
|
||||
setContentType,
|
||||
setCombinedMode,
|
||||
setSearchInput,
|
||||
setAdvancedFilters,
|
||||
setShowAdvanced,
|
||||
@@ -49,11 +55,19 @@ export const UrlSearchBootstrapMount = ({
|
||||
const parsedSearchMode = config.search_mode || 'universal';
|
||||
const urlContentTypeOverride =
|
||||
parsedSearchMode === 'universal' ? parsedParams.contentType : undefined;
|
||||
const urlForcesCombined =
|
||||
parsedSearchMode === 'universal' && parsedParams.combinedMode === true && combinedModeAllowed;
|
||||
|
||||
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
|
||||
setContentType(urlContentTypeOverride);
|
||||
}
|
||||
|
||||
if (urlForcesCombined && !combinedMode) {
|
||||
setCombinedMode(true);
|
||||
} else if (urlContentTypeOverride && combinedMode) {
|
||||
setCombinedMode(false);
|
||||
}
|
||||
|
||||
if (!parsedParams.hasSearchParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchRetu
|
||||
}
|
||||
|
||||
const parsed = parseUrlSearchParams(searchParams);
|
||||
return parsed.hasSearchParams || parsed.contentType ? parsed : null;
|
||||
return parsed.hasSearchParams || parsed.contentType || parsed.combinedMode ? parsed : null;
|
||||
}, [enabled, searchParams]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -42,4 +42,22 @@ describe('parseUrlSearchParams', () => {
|
||||
expect(parsed.hasSearchParams).toBe(false);
|
||||
expect(parsed.contentType).toBe('ebook');
|
||||
});
|
||||
|
||||
it('parses content_type=combined as a combined-mode override', () => {
|
||||
const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=combined'));
|
||||
|
||||
expect(parsed.searchInput).toBe('dune');
|
||||
expect(parsed.hasSearchParams).toBe(true);
|
||||
expect(parsed.contentType).toBe(undefined);
|
||||
expect(parsed.combinedMode).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps combined-only links from auto-triggering a blank search', () => {
|
||||
const parsed = parseUrlSearchParams(new URLSearchParams('content_type=combined'));
|
||||
|
||||
expect(parsed.searchInput).toBe('');
|
||||
expect(parsed.hasSearchParams).toBe(false);
|
||||
expect(parsed.contentType).toBe(undefined);
|
||||
expect(parsed.combinedMode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,6 +280,7 @@ export interface AppConfig {
|
||||
default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks
|
||||
show_release_source_links: boolean;
|
||||
show_combined_selector: boolean;
|
||||
force_combined_search: boolean;
|
||||
books_output_mode: BooksOutputMode;
|
||||
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
||||
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
|
||||
|
||||
@@ -7,18 +7,24 @@ export interface ParsedUrlSearch {
|
||||
searchInput: string;
|
||||
advancedFilters: Partial<AdvancedFilterState>;
|
||||
contentType?: ContentType;
|
||||
combinedMode?: boolean;
|
||||
hasSearchParams: boolean;
|
||||
}
|
||||
|
||||
const parseContentType = (value: string | null): ContentType | undefined => {
|
||||
const parseContentTypeParam = (
|
||||
value: string | null,
|
||||
): { contentType?: ContentType; combinedMode?: true } => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'ebook' || normalized === 'audiobook') {
|
||||
return normalized;
|
||||
return { contentType: normalized };
|
||||
}
|
||||
return undefined;
|
||||
if (normalized === 'combined') {
|
||||
return { combinedMode: true };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,21 +32,23 @@ const parseContentType = (value: string | null): ContentType | undefined => {
|
||||
*
|
||||
* Supports both Direct Download and Universal mode parameters.
|
||||
* In Universal mode, query/sort are used for search text, and content_type
|
||||
* is used to select ebook vs audiobook.
|
||||
* selects ebook, audiobook, or combined (search both at once).
|
||||
*
|
||||
* @example
|
||||
* // Direct mode: /?q=harry+potter&author=rowling&format=epub&lang=en
|
||||
* // Universal mode: /?q=dune&sort=popularity
|
||||
* // Universal combined: /?q=dune&content_type=combined
|
||||
*/
|
||||
export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch {
|
||||
const parsedContentType = parseContentType(
|
||||
const contentTypeParam = parseContentTypeParam(
|
||||
searchParams.get('content_type') || searchParams.get('contentType'),
|
||||
);
|
||||
|
||||
const result: ParsedUrlSearch = {
|
||||
searchInput: '',
|
||||
advancedFilters: {},
|
||||
contentType: parsedContentType,
|
||||
contentType: contentTypeParam.contentType,
|
||||
combinedMode: contentTypeParam.combinedMode,
|
||||
hasSearchParams: false,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user