diff --git a/docs/url-search-parameters.md b/docs/url-search-parameters.md index 53d332d6..88ed565f 100644 --- a/docs/url-search-parameters.md +++ b/docs/url-search-parameters.md @@ -1,71 +1,79 @@ # URL Search Parameters -You can trigger searches directly via URL by adding query parameters. This enables bookmarking searches and sharing links. +You can trigger searches directly via URL. This enables bookmarking searches and sharing links. + +Parameters live in the URL **hash** (`#…`), so they stay in the browser and are never sent to +the server. Shelfmark also keeps the hash in sync as you search, so the address bar always +holds a shareable link to what you're looking at. ## Basic Usage ``` -http://your-server:8084/?q=harry+potter +http://your-server:8084/#q=harry+potter ``` +Older query-string links (`/?q=harry+potter`) still work: they're read once on load and +rewritten to the hash form. + ## Supported Parameters | Parameter | Description | Example | |-----------|-------------|---------| -| `q` or `query` | Main search query | `/?q=dune` | -| `author` | Filter by author name | `/?author=frank+herbert` | -| `title` | Filter by book title | `/?title=foundation` | -| `isbn` | Filter by ISBN | `/?isbn=978-0747532699` | -| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` | -| `format` | Filter by file format | `/?format=epub` | -| `content` | Filter by content type | `/?content=fiction` | -| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/?q=dune&content_type=audiobook` | -| `sort` | Sort order for results | `/?sort=newest` | +| `q` or `query` | Main search query | `/#q=dune` | +| `author` | Filter by author name | `/#author=frank+herbert` | +| `title` | Filter by book title | `/#title=foundation` | +| `isbn` | Filter by ISBN | `/#isbn=978-0747532699` | +| `lang` | Filter by language (ISO 639-1 code) | `/#lang=en` | +| `format` | Filter by file format | `/#format=epub` | +| `content` | Filter by content type | `/#content=fiction` | +| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/#q=dune&content_type=audiobook` | +| `sort` | Sort order for results | `/#sort=newest` | +| `search_by` | "Search By" target the query applies to (`general`, `author`, `title`, `isbn`, a metadata provider field like `series`, or `manual`) | `/#search_by=author&q=frank+herbert` | ## Multiple Values Some parameters support multiple values by repeating the parameter: ``` -/?lang=en&lang=de&lang=fr -/?format=epub&format=mobi&format=azw3 +/#lang=en&lang=de&lang=fr +/#format=epub&format=mobi&format=azw3 ``` ## Examples **Simple search:** ``` -/?q=lord+of+the+rings +/#q=lord+of+the+rings ``` **Search with author filter:** ``` -/?q=dune&author=frank+herbert +/#q=dune&author=frank+herbert ``` **Search with format and language:** ``` -/?q=harry+potter&format=epub&lang=en +/#q=harry+potter&format=epub&lang=en ``` **Author search with multiple formats:** ``` -/?author=stephen+king&format=epub&format=mobi +/#author=stephen+king&format=epub&format=mobi ``` **Search with sort order:** ``` -/?q=science+fiction&sort=newest +/#q=science+fiction&sort=newest ``` **Universal search as audiobook:** ``` -/?q=dune&content_type=audiobook +/#q=dune&content_type=audiobook ``` **Universal search forcing combined (ebook + audiobook):** ``` -/?q=dune&content_type=combined +/#q=dune&content_type=combined ``` ## Search Mode Behavior @@ -77,13 +85,28 @@ When Search Mode is set to Direct, all parameters are used to filter results fro ### Universal Mode -`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities. +`q`, `search_by`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities — except when `search_by` names one of the provider's own search fields, in which case `q` is sent as that field's value. `content_type=combined` forces combined mode (search ebook and audiobook providers together), overriding the last-used preference. It is silently ignored if combined mode is unavailable (e.g. the combined selector is disabled in settings, or either content type is blocked by request policy). +## Search By + +`search_by` picks which target the `q` value is applied to, matching the selector next to the +search box. It can be deep-linked on its own (`/#search_by=manual`) to open the app in that +mode with an empty query. + +`search_by=manual` fills the search box but does not auto-run: manual search opens the release +browser from an explicit submit. + +A `search_by` naming a target that isn't available (wrong search mode, or a metadata provider +that doesn't offer that field) is ignored, and the query falls back to a general search. + ## Notes -- URL parameters are read once on page load -- The URL is not updated when you perform searches manually +- URL parameters are read once on page load, and again if the hash is replaced in an open tab + (e.g. pasting a shared link into the address bar) +- The hash is kept in sync with the search box, Search By target and filters as you search - Spaces should be encoded as `+` or `%20` - Invalid or unknown parameters are silently ignored +- Your last-used Search By target is remembered in browser storage and used when a link + doesn't specify one diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 17c4c729..05191cc1 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -41,7 +41,7 @@ import { useRequests } from './hooks/useRequests'; import { useSearch } from './hooks/useSearch'; import { primeSettingsCache } from './hooks/useSettings'; import { useToast } from './hooks/useToast'; -import { useUrlSearch } from './hooks/useUrlSearch'; +import { useExternalHashChange, useSyncUrlSearchHash, useUrlSearch } from './hooks/useUrlSearch'; import { primeUsersCache } from './hooks/useUsersFetch'; import { LoginPage } from './pages/LoginPage'; import { @@ -93,7 +93,7 @@ import { getConfiguredMetadataProviderForContentType } from './utils/metadataPro import { getEffectiveMetadataSort } from './utils/metadataSort'; import { isRecord } from './utils/objectHelpers'; import { policyTrace } from './utils/policyTrace'; -import { buildQueryTargets, getDefaultQueryTargetKey } from './utils/queryTargets'; +import { buildQueryTargets, findQueryTarget, getDefaultQueryTargetKey } from './utils/queryTargets'; import { buildReleaseDownloadPayload, type ReleaseDownloadOptions } from './utils/releasePayload'; import { applyRequestNoteToPayload } from './utils/requestConfirmation'; import { bookFromRequestData } from './utils/requestFulfil'; @@ -110,6 +110,8 @@ import { applyDirectPolicyModeToButtonState, applyUniversalPolicyModeToButtonState, } from './utils/requestPolicyUi'; +import { getSearchByPreference, setSearchByPreference } from './utils/searchByPreference'; +import { buildUrlSearchHash } from './utils/urlSearchHash'; // eslint-disable-next-line import/no-unassigned-import -- global app stylesheet is loaded for side effects import './styles.css'; @@ -617,7 +619,12 @@ function App() { const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState< string | null >(null); - const [activeQueryTarget, setActiveQueryTarget] = useState('general'); + // Falls back to the stored "Search By" default from the user's last-used mode; + // an invalid/stale value is harmless since effectiveActiveQueryTarget below re-validates + // it against the current queryTargets once config/search fields are known. + const [activeQueryTarget, setActiveQueryTarget] = useState( + () => getSearchByPreference() || 'general', + ); const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(() => getInitialPinnedPreference(), @@ -693,8 +700,18 @@ function App() { // URL-based search: parse URL params for automatic search on page load const urlSearchEnabled = isAuthenticated && config !== null; - const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled }); + // Bumped when the hash changes to something we didn't write - a shared link pasted into + // an already-open tab. Re-parses the URL and remounts the bootstrap so it applies. + const [urlSearchNonce, setUrlSearchNonce] = useState(0); + const { parsedParams, wasProcessed } = useUrlSearch({ + enabled: urlSearchEnabled, + nonce: urlSearchNonce, + }); const [hasExecutedUrlSearchBootstrap, setHasExecutedUrlSearchBootstrap] = useState(false); + useExternalHashChange(() => { + setHasExecutedUrlSearchBootstrap(false); + setUrlSearchNonce((value) => value + 1); + }); const prevSearchModeRef = useRef(undefined); @@ -1934,6 +1951,15 @@ function App() { return getDefaultQueryTargetKey(queryTargets); }, [queryTargets, activeQueryTarget]); + // Persist only what the user explicitly picked in the selector. Persisting the derived + // `effectiveActiveQueryTarget` instead would overwrite the stored default with `general` + // every time it collapses for reasons the user didn't choose: a cold load before the + // metadata search fields resolve, the logo reset, logout, or a `view_series` browse. + const handleQueryTargetChange = useCallback((nextTarget: string) => { + setActiveQueryTarget(nextTarget); + setSearchByPreference(nextTarget); + }, []); + const activeQueryOption = useMemo( () => queryTargets.find((target) => target.key === effectiveActiveQueryTarget) ?? queryTargets[0], @@ -1986,6 +2012,23 @@ function App() { return searchFieldValues[activeQueryOption.field.key] ?? ''; }, [activeQueryOption, searchInput, searchFieldValues]); + // 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 + // link's params with the initial default state before they've been read. + const readyToSyncUrlHash = wasProcessed && (!parsedParams || hasExecutedUrlSearchBootstrap); + const urlSearchHash = useMemo( + () => + buildUrlSearchHash({ + queryValue: activeQueryValue, + searchBy: effectiveActiveQueryTarget, + contentType, + combinedMode, + advancedFilters, + }), + [activeQueryValue, effectiveActiveQueryTarget, contentType, combinedMode, advancedFilters], + ); + useSyncUrlSearchHash({ enabled: readyToSyncUrlHash, hash: urlSearchHash }); + const activeQueryValueLabel = useMemo(() => { if (!activeQueryOption?.field) { return undefined; @@ -2401,7 +2444,7 @@ function App() { onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} queryTargets={queryTargets} activeQueryTarget={effectiveActiveQueryTarget} - onQueryTargetChange={setActiveQueryTarget} + onQueryTargetChange={handleQueryTargetChange} activeQueryField={activeQueryField} /> @@ -2468,7 +2511,7 @@ function App() { onQueryValueChange={handleActiveQueryValueChange} queryTargets={queryTargets} activeQueryTarget={effectiveActiveQueryTarget} - onQueryTargetChange={setActiveQueryTarget} + onQueryTargetChange={handleQueryTargetChange} showAdvanced={effectiveShowAdvanced} onAdvancedToggle={ hasAdvancedContent ? () => setShowAdvanced(!effectiveShowAdvanced) : undefined @@ -2749,14 +2792,31 @@ function App() { const adminSettingsWarmup = adminSettingsWarmupKey ? ( ) : null; + // A `search_by` deep link can name a metadata provider field that isn't in queryTargets + // until the search-fields fetch resolves. Bootstrapping before then runs the search + // against the wrong target *and* lets the sync effect rewrite the shared hash without + // `search_by`, so hold the one-shot mount until the fields have settled (the session + // resolves to null on failure, so this can't hang). + const searchFieldsSettled = + metadataConfigSessionKey === null || + activeMetadataConfigState?.sessionKey === metadataConfigSessionKey; + const awaitingSearchByTarget = Boolean( + parsedParams?.searchBy && !findQueryTarget(queryTargets, parsedParams.searchBy), + ); const urlSearchBootstrapMount = - wasProcessed && parsedParams && config && !hasExecutedUrlSearchBootstrap ? ( + wasProcessed && + parsedParams && + config && + !hasExecutedUrlSearchBootstrap && + (searchFieldsSettled || !awaitingSearchByTarget) ? ( { setHasExecutedUrlSearchBootstrap(true); diff --git a/src/frontend/src/components/UrlSearchBootstrapMount.tsx b/src/frontend/src/components/UrlSearchBootstrapMount.tsx index 68842527..f44c4ba9 100644 --- a/src/frontend/src/components/UrlSearchBootstrapMount.tsx +++ b/src/frontend/src/components/UrlSearchBootstrapMount.tsx @@ -1,11 +1,19 @@ import type { Dispatch, SetStateAction } from 'react'; import { useMountEffect } from '@/hooks/useMountEffect'; -import type { AppConfig, AdvancedFilterState, ContentType, SearchMode, SortOption } from '@/types'; +import type { + AppConfig, + AdvancedFilterState, + ContentType, + QueryTargetOption, + SearchMode, + SortOption, +} from '@/types'; import { buildSearchQuery } from '@/utils/buildSearchQuery'; import { resolveDefaultLanguageCodes } from '@/utils/languageFilters'; import { getEffectiveMetadataSort } from '@/utils/metadataSort'; import type { ParsedUrlSearch } from '@/utils/parseUrlSearchParams'; +import { findQueryTarget } from '@/utils/queryTargets'; const ADVANCED_FILTER_VISIBILITY_KEYS = ['content', 'lang', 'formats'] as const; @@ -15,6 +23,7 @@ interface UrlSearchBootstrapMountProps { contentType: ContentType; combinedMode: boolean; combinedModeAllowed: boolean; + queryTargets: QueryTargetOption[]; advancedFilters: AdvancedFilterState; resolvedMetadataDefaultSort: string; resolvedMetadataSortOptions: SortOption[]; @@ -24,10 +33,12 @@ interface UrlSearchBootstrapMountProps { setAdvancedFilters: Dispatch>; setShowAdvanced: (value: boolean) => void; setActiveQueryTarget: (value: string) => void; + setSearchFieldValue: (key: string, value: string | number | boolean, label?: string) => void; runSearchWithPolicyRefresh: (opts: { query: string; contentTypeOverride?: ContentType; searchModeOverride?: SearchMode; + fieldValues?: Record; }) => void; onComplete: () => void; } @@ -38,6 +49,7 @@ export const UrlSearchBootstrapMount = ({ contentType, combinedMode, combinedModeAllowed, + queryTargets, advancedFilters, resolvedMetadataDefaultSort, resolvedMetadataSortOptions, @@ -47,6 +59,7 @@ export const UrlSearchBootstrapMount = ({ setAdvancedFilters, setShowAdvanced, setActiveQueryTarget, + setSearchFieldValue, runSearchWithPolicyRefresh, onComplete, }: UrlSearchBootstrapMountProps) => { @@ -69,6 +82,15 @@ export const UrlSearchBootstrapMount = ({ setCombinedMode(false); } + const urlSearchByTarget = findQueryTarget(queryTargets, parsedParams.searchBy); + const urlSearchByOverride = urlSearchByTarget?.key; + + // Search By target can be deep-linked on its own (e.g. `#search_by=manual`, no query), + // so apply it even when there's nothing else to search for. + if (urlSearchByOverride) { + setActiveQueryTarget(urlSearchByOverride); + } + if (!parsedParams.hasSearchParams) { return; } @@ -79,12 +101,8 @@ export const UrlSearchBootstrapMount = ({ bookLanguages, ); - if (parsedParams.searchInput) { - setSearchInput(parsedParams.searchInput); - } - - let nextQueryTarget = 'general'; - if (parsedSearchMode === 'direct') { + let nextQueryTarget = urlSearchByOverride || 'general'; + if (parsedSearchMode === 'direct' && !urlSearchByOverride) { if (parsedParams.advancedFilters.isbn) { nextQueryTarget = 'isbn'; } else if (parsedParams.advancedFilters.author) { @@ -95,6 +113,39 @@ export const UrlSearchBootstrapMount = ({ } setActiveQueryTarget(nextQueryTarget); + // Route `q` through the active target, mirroring how the live search dispatch reads it: + // direct fields and text fields are typed into searchInput, other provider fields are + // dispatched as fieldValues. Legacy links carry the value under the field's own param + // (`?author=herbert`) instead, so fall back to that. + const targetKey = urlSearchByTarget?.field?.key ?? nextQueryTarget; + const legacyDirectValue = + targetKey === 'isbn' || targetKey === 'author' || targetKey === 'title' + ? parsedParams.advancedFilters[targetKey] + : undefined; + const targetQueryValue = parsedParams.searchInput || legacyDirectValue || ''; + + const usesProviderFieldValue = + urlSearchByTarget?.source === 'provider-field' && + urlSearchByTarget.field !== undefined && + urlSearchByTarget.field.type !== 'TextSearchField'; + + if (usesProviderFieldValue && urlSearchByTarget?.field && targetQueryValue) { + setSearchFieldValue(urlSearchByTarget.field.key, targetQueryValue); + } else if (targetQueryValue) { + setSearchInput(targetQueryValue); + } + + const urlFieldValues = + urlSearchByTarget?.source === 'provider-field' && urlSearchByTarget.field && targetQueryValue + ? { [urlSearchByTarget.field.key]: targetQueryValue } + : undefined; + + // Manual mode opens the release browser off an explicit submit - it has no results + // list to bootstrap, so a deep link fills the input and stops there. + if (urlSearchByTarget?.source === 'manual') { + return; + } + const resolvedUrlMetadataSort = parsedSearchMode === 'universal' ? getEffectiveMetadataSort({ @@ -134,16 +185,13 @@ export const UrlSearchBootstrapMount = ({ }; const query = buildSearchQuery({ - searchInput: - parsedSearchMode === 'direct' && nextQueryTarget !== 'general' - ? '' - : parsedParams.searchInput, + searchInput: nextQueryTarget === 'general' ? targetQueryValue : '', showAdvanced: true, advancedFilters: { ...mergedFilters, - isbn: nextQueryTarget === 'isbn' ? parsedParams.advancedFilters.isbn || '' : '', - author: nextQueryTarget === 'author' ? parsedParams.advancedFilters.author || '' : '', - title: nextQueryTarget === 'title' ? parsedParams.advancedFilters.title || '' : '', + isbn: nextQueryTarget === 'isbn' ? targetQueryValue : '', + author: nextQueryTarget === 'author' ? targetQueryValue : '', + title: nextQueryTarget === 'title' ? targetQueryValue : '', }, bookLanguages, defaultLanguage: defaultLanguageCodes, @@ -154,6 +202,7 @@ export const UrlSearchBootstrapMount = ({ query, contentTypeOverride: urlContentTypeOverride, searchModeOverride: parsedSearchMode, + fieldValues: urlFieldValues, }); }); diff --git a/src/frontend/src/hooks/useUrlSearch.ts b/src/frontend/src/hooks/useUrlSearch.ts index 60c106ab..185eb03b 100644 --- a/src/frontend/src/hooks/useUrlSearch.ts +++ b/src/frontend/src/hooks/useUrlSearch.ts @@ -1,12 +1,18 @@ import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; import type { ParsedUrlSearch } from '../utils/parseUrlSearchParams'; import { parseUrlSearchParams } from '../utils/parseUrlSearchParams'; +import { useLatestCallback } from './useLatestCallback'; +import { useDependencyEffect, useMountEffect } from './useMountEffect'; interface UseUrlSearchOptions { /** Only process URL params after auth check and config are loaded */ enabled: boolean; + /** + * Bump to re-read the URL - used when the hash changes underneath us + * (someone pastes a shared link into an already-open tab). + */ + nonce?: number; } interface UseUrlSearchReturn { @@ -16,11 +22,39 @@ interface UseUrlSearchReturn { wasProcessed: boolean; } +/** Debounce for the write side: searchInput changes on every keystroke, and Safari + * throws SecurityError past ~100 replaceState calls per 30s. */ +const HASH_SYNC_DEBOUNCE_MS = 300; + /** - * Hook to parse URL search parameters on initial page load. + * Last hash this module wrote. Lets the hashchange listener tell "the user pasted a + * new link" apart from "our own sync effect just ran". + */ +let lastWrittenHash: string | null = null; + +const stripHash = (value: string): string => (value.startsWith('#') ? value.slice(1) : value); + +const readUrlSearchParams = (): { params: URLSearchParams; fromQueryString: boolean } => { + const hash = stripHash(window.location.hash); + if (hash) { + return { params: new URLSearchParams(hash), fromQueryString: false }; + } + const search = window.location.search.startsWith('?') + ? window.location.search.slice(1) + : window.location.search; + return { params: new URLSearchParams(search), fromQueryString: Boolean(search) }; +}; + +/** + * Hook to parse the URL on initial page load. * - * This is a read-only operation - URL params are parsed once when enabled, - * and the URL is not updated when users perform searches. + * Search config lives in a hash fragment (e.g. `#q=dune&search_by=manual`) + * rather than query params, so it stays browser-side only and never looks + * like a server-processed query string. + * + * Query-string links (`?q=dune`) shipped before the hash and are still honoured + * when the hash is empty: they're read once and rewritten to `#…` so the two + * can't drift as the live sync below updates the URL. * * @example * // In App.tsx: @@ -33,19 +67,112 @@ interface UseUrlSearchReturn { * // Trigger search with parsed params * } */ -export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchReturn { - const [searchParams] = useSearchParams(); - const parsedParams = useMemo(() => { +export function useUrlSearch({ enabled, nonce = 0 }: UseUrlSearchOptions): UseUrlSearchReturn { + const read = useMemo(() => { if (!enabled) { return null; } - const parsed = parseUrlSearchParams(searchParams); - return parsed.hasSearchParams || parsed.contentType || parsed.combinedMode ? parsed : null; - }, [enabled, searchParams]); + const { params, fromQueryString } = readUrlSearchParams(); + const parsed = parseUrlSearchParams(params); + const hasAnything = Boolean( + parsed.hasSearchParams || parsed.contentType || parsed.combinedMode || parsed.searchBy, + ); + + return { parsed: hasAnything ? parsed : null, fromQueryString, hasAnything }; + // Intentionally read once per enable/nonce - live updates come from useSyncUrlSearchHash. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, nonce]); + + // Migrate a legacy query-string link to the hash form once, so the live sync has a single + // source of truth and the URL the user re-shares is the one the app keeps updating. + const shouldMigrate = Boolean(read?.fromQueryString && read.hasAnything); + useDependencyEffect(() => { + if (!shouldMigrate) { + return; + } + + const nextHash = new URLSearchParams(window.location.search).toString(); + lastWrittenHash = nextHash; + try { + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}#${nextHash}`, + ); + } catch { + // replaceState is rate-limited in Safari - the parsed params still apply + } + }, [shouldMigrate]); return { - parsedParams, + parsedParams: read?.parsed ?? null, wasProcessed: enabled, }; } + +interface UseSyncUrlSearchHashOptions { + /** Only write once URL params (if any) have been applied to search state */ + enabled: boolean; + /** Hash fragment (without leading `#`) that should reflect current search state */ + hash: string; +} + +/** + * Keeps the URL hash fragment in sync with the current search state. + * + * Uses history.replaceState (not pushState), so every keystroke or + * Search By change updates the URL live without pushing a new browser + * history entry per change. Debounced, because Safari throws SecurityError + * past roughly 100 replaceState calls per 30 seconds. + */ +export function useSyncUrlSearchHash({ enabled, hash }: UseSyncUrlSearchHashOptions): void { + useDependencyEffect(() => { + // Debounced, so a burst of keystrokes collapses into one history write. + const timer = enabled + ? window.setTimeout(() => { + if (stripHash(window.location.hash) === hash) { + lastWrittenHash = hash; + return; + } + + const url = `${window.location.pathname}${window.location.search}${hash ? `#${hash}` : ''}`; + lastWrittenHash = hash; + try { + window.history.replaceState(window.history.state, '', url); + } catch { + // Rate-limited (Safari) or a sandboxed frame - the UI state is still correct, + // only the shareable URL lags behind. + } + }, HASH_SYNC_DEBOUNCE_MS) + : undefined; + + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + }, [enabled, hash]); +} + +/** + * Calls `onExternalChange` when the hash changes to something this module didn't write - + * i.e. someone pasted a shared link into an already-open tab, or used back/forward. + */ +export function useExternalHashChange(onExternalChange: () => void): void { + // The listener outlives the Effect that registers it and fires from a DOM event, + // which is outside useEffectEvent's contract - see useLatestCallback. + const notify = useLatestCallback(onExternalChange); + + useMountEffect(() => { + const handleHashChange = () => { + if (stripHash(window.location.hash) === lastWrittenHash) { + return; + } + notify(); + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }); +} diff --git a/src/frontend/src/tests/parseUrlSearchParams.test.ts b/src/frontend/src/tests/parseUrlSearchParams.test.ts index 8f74ab79..4d667b65 100644 --- a/src/frontend/src/tests/parseUrlSearchParams.test.ts +++ b/src/frontend/src/tests/parseUrlSearchParams.test.ts @@ -60,4 +60,32 @@ describe('parseUrlSearchParams', () => { expect(parsed.contentType).toBe(undefined); expect(parsed.combinedMode).toBe(true); }); + + it('parses search_by as the Search By target', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('search_by=manual&q=dune')); + + expect(parsed.searchBy).toBe('manual'); + expect(parsed.searchInput).toBe('dune'); + expect(parsed.hasSearchParams).toBe(true); + }); + + it('trims search_by but keeps its casing for case-sensitive provider field keys', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('search_by=+MANUAL+')); + + expect(parsed.searchBy).toBe('MANUAL'); + }); + + it('keeps search_by-only links from auto-triggering a blank search', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('search_by=manual')); + + expect(parsed.searchBy).toBe('manual'); + expect(parsed.searchInput).toBe(''); + expect(parsed.hasSearchParams).toBe(false); + }); + + it('leaves search_by undefined when absent', () => { + const parsed = parseUrlSearchParams(new URLSearchParams('q=dune')); + + expect(parsed.searchBy).toBe(undefined); + }); }); diff --git a/src/frontend/src/tests/queryTargets.test.ts b/src/frontend/src/tests/queryTargets.test.ts index a10d05e3..9e4f8e2b 100644 --- a/src/frontend/src/tests/queryTargets.test.ts +++ b/src/frontend/src/tests/queryTargets.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { buildQueryTargets, getDefaultQueryTargetKey } from '../utils/queryTargets'; +import { + buildQueryTargets, + findQueryTarget, + getDefaultQueryTargetKey, +} from '../utils/queryTargets'; describe('queryTargets', () => { it('builds direct-mode query targets', () => { @@ -44,3 +48,33 @@ describe('queryTargets', () => { expect(getDefaultQueryTargetKey([])).toBe('general'); }); }); + +describe('findQueryTarget', () => { + const targets = buildQueryTargets({ searchMode: 'direct' }); + + it('returns undefined for a missing or empty key', () => { + expect(findQueryTarget(targets, undefined)).toBeUndefined(); + expect(findQueryTarget(targets, '')).toBeUndefined(); + expect(findQueryTarget(targets, 'series')).toBeUndefined(); + }); + + it('matches an exact key', () => { + expect(findQueryTarget(targets, 'author')?.key).toBe('author'); + }); + + it('falls back to a case-insensitive match for custom provider field keys', () => { + const providerTargets = buildQueryTargets({ + searchMode: 'universal', + metadataSearchFields: [ + { + key: 'hardcoverList', + label: 'List', + type: 'TextSearchField', + }, + ], + }); + + expect(findQueryTarget(providerTargets, 'hardcoverlist')?.key).toBe('hardcoverList'); + expect(findQueryTarget(providerTargets, 'hardcoverList')?.key).toBe('hardcoverList'); + }); +}); diff --git a/src/frontend/src/tests/searchByPreference.test.ts b/src/frontend/src/tests/searchByPreference.test.ts new file mode 100644 index 00000000..a9e6e57d --- /dev/null +++ b/src/frontend/src/tests/searchByPreference.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSearchByPreference, setSearchByPreference } from '../utils/searchByPreference'; + +// No jsdom in this project's vitest setup, so stub the slice of localStorage this uses. +const makeStorage = (impl?: Partial) => { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + ...impl, + }; +}; + +const stubWindow = (storage: unknown) => { + vi.stubGlobal('window', { localStorage: storage }); +}; + +describe('searchByPreference', () => { + beforeEach(() => { + stubWindow(makeStorage()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns null when nothing is stored', () => { + expect(getSearchByPreference()).toBe(null); + }); + + it('round-trips a value through set/get', () => { + setSearchByPreference('manual'); + expect(getSearchByPreference()).toBe('manual'); + }); + + it('overwrites a previously stored value', () => { + setSearchByPreference('manual'); + setSearchByPreference('author'); + expect(getSearchByPreference()).toBe('author'); + }); + + it('survives storage being unavailable', () => { + stubWindow( + makeStorage({ + getItem: () => { + throw new Error('storage disabled'); + }, + setItem: () => { + throw new Error('storage disabled'); + }, + }), + ); + + expect(() => setSearchByPreference('author')).not.toThrow(); + expect(getSearchByPreference()).toBe(null); + }); +}); diff --git a/src/frontend/src/tests/urlSearchHash.test.ts b/src/frontend/src/tests/urlSearchHash.test.ts new file mode 100644 index 00000000..60cd0623 --- /dev/null +++ b/src/frontend/src/tests/urlSearchHash.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { buildUrlSearchHash } from '../utils/urlSearchHash'; + +describe('buildUrlSearchHash', () => { + it('builds a hash reflecting query, search_by and a non-default content_type', () => { + const hash = buildUrlSearchHash({ + queryValue: 'dune', + searchBy: 'manual', + contentType: 'audiobook', + combinedMode: false, + advancedFilters: {}, + }); + + expect(new URLSearchParams(hash).get('q')).toBe('dune'); + expect(new URLSearchParams(hash).get('search_by')).toBe('manual'); + expect(new URLSearchParams(hash).get('content_type')).toBe('audiobook'); + }); + + it('omits content_type when it is the ebook default', () => { + const hash = buildUrlSearchHash({ + queryValue: 'dune', + searchBy: 'general', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + + expect(new URLSearchParams(hash).has('content_type')).toBe(false); + }); + + it('omits search_by when it is the general default', () => { + const hash = buildUrlSearchHash({ + queryValue: 'dune', + searchBy: 'general', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + + expect(new URLSearchParams(hash).has('search_by')).toBe(false); + }); + + it('sets content_type=combined when combinedMode is true', () => { + const hash = buildUrlSearchHash({ + queryValue: 'dune', + searchBy: 'general', + contentType: 'ebook', + combinedMode: true, + advancedFilters: {}, + }); + + expect(new URLSearchParams(hash).get('content_type')).toBe('combined'); + }); + + it('mirrors advanced filters (isbn/author/title/sort/content/lang/format)', () => { + const hash = buildUrlSearchHash({ + queryValue: '', + searchBy: 'author', + contentType: 'ebook', + combinedMode: false, + advancedFilters: { + author: 'frank herbert', + lang: ['en', 'de'], + formats: ['epub'], + sort: 'newest', + }, + }); + + const params = new URLSearchParams(hash); + expect(params.get('author')).toBe('frank herbert'); + expect(params.get('sort')).toBe('newest'); + expect(params.getAll('lang')).toEqual(['en', 'de']); + expect(params.getAll('format')).toEqual(['epub']); + }); + + it('produces an empty string when there is nothing to reflect', () => { + const hash = buildUrlSearchHash({ + queryValue: '', + searchBy: 'general', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + + expect(hash).toBe(''); + }); + + it('serializes a non-text provider field value so the target round-trips', () => { + const hash = buildUrlSearchHash({ + queryValue: 'id:1234', + searchBy: 'series', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + + expect(new URLSearchParams(hash).get('q')).toBe('id:1234'); + expect(new URLSearchParams(hash).get('search_by')).toBe('series'); + }); + + it('serializes numeric and checkbox field values', () => { + const numeric = buildUrlSearchHash({ + queryValue: 2024, + searchBy: 'year', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + expect(new URLSearchParams(numeric).get('q')).toBe('2024'); + + const checked = buildUrlSearchHash({ + queryValue: true, + searchBy: 'signed', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + expect(new URLSearchParams(checked).get('q')).toBe('1'); + + const unchecked = buildUrlSearchHash({ + queryValue: false, + searchBy: 'signed', + contentType: 'ebook', + combinedMode: false, + advancedFilters: {}, + }); + expect(new URLSearchParams(unchecked).has('q')).toBe(false); + }); +}); diff --git a/src/frontend/src/utils/parseUrlSearchParams.ts b/src/frontend/src/utils/parseUrlSearchParams.ts index a37c3f97..e04faca6 100644 --- a/src/frontend/src/utils/parseUrlSearchParams.ts +++ b/src/frontend/src/utils/parseUrlSearchParams.ts @@ -8,6 +8,12 @@ export interface ParsedUrlSearch { advancedFilters: Partial; contentType?: ContentType; combinedMode?: boolean; + /** + * "Search By" target (e.g. general/author/title/series/manual), from the `search_by` + * param. Kept verbatim - provider field keys are matched case-insensitively against + * the live targets, so a custom provider's camelCase key still resolves. + */ + searchBy?: string; hasSearchParams: boolean; } @@ -34,21 +40,28 @@ const parseContentTypeParam = ( * In Universal mode, query/sort are used for search text, and content_type * selects ebook, audiobook, or combined (search both at once). * + * Params live in the URL hash (legacy query strings are still accepted and + * rewritten to a hash on load - see useUrlSearch). + * * @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 + * // Direct mode: /#q=harry+potter&author=rowling&format=epub&lang=en + * // Universal mode: /#q=dune&sort=popularity + * // Universal combined: /#q=dune&content_type=combined + * // Search By deep link: /#search_by=manual&q=dune */ export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch { const contentTypeParam = parseContentTypeParam( searchParams.get('content_type') || searchParams.get('contentType'), ); + const searchByParam = (searchParams.get('search_by') || '').trim(); + const result: ParsedUrlSearch = { searchInput: '', advancedFilters: {}, contentType: contentTypeParam.contentType, combinedMode: contentTypeParam.combinedMode, + searchBy: searchByParam || undefined, hasSearchParams: false, }; diff --git a/src/frontend/src/utils/queryTargets.ts b/src/frontend/src/utils/queryTargets.ts index 5e43c6ed..e1f0fd15 100644 --- a/src/frontend/src/utils/queryTargets.ts +++ b/src/frontend/src/utils/queryTargets.ts @@ -85,3 +85,24 @@ export const buildQueryTargets = ({ export const getDefaultQueryTargetKey = (targets: QueryTargetOption[]): string => { return targets[0]?.key || 'general'; }; + +/** + * Resolve a "Search By" key (e.g. from a URL hash) against the live targets. + * + * Exact match first, then case-insensitive: built-in keys are lowercase, but a + * custom metadata provider can declare a camelCase field key. + */ +export const findQueryTarget = ( + targets: QueryTargetOption[], + key: string | undefined, +): QueryTargetOption | undefined => { + if (!key) { + return undefined; + } + const exact = targets.find((target) => target.key === key); + if (exact) { + return exact; + } + const lowered = key.toLowerCase(); + return targets.find((target) => target.key.toLowerCase() === lowered); +}; diff --git a/src/frontend/src/utils/searchByPreference.ts b/src/frontend/src/utils/searchByPreference.ts new file mode 100644 index 00000000..4dc2f320 --- /dev/null +++ b/src/frontend/src/utils/searchByPreference.ts @@ -0,0 +1,25 @@ +const SEARCH_BY_STORAGE_KEY = 'shelfmark_search_by'; + +/** + * Reads the client-side default "Search By" target the user last picked. + * + * localStorage (not a user account/setting) so it works without any server-side + * user management, and unlike a cookie it isn't sent on every HTTP request - + * it's a purely browser-side UI preference. + */ +export const getSearchByPreference = (): string | null => { + try { + return window.localStorage.getItem(SEARCH_BY_STORAGE_KEY); + } catch { + // localStorage can throw when storage is disabled or the quota is exhausted + return null; + } +}; + +export const setSearchByPreference = (value: string): void => { + try { + window.localStorage.setItem(SEARCH_BY_STORAGE_KEY, value); + } catch { + // Preference is best-effort - a failure here must not break search + } +}; diff --git a/src/frontend/src/utils/urlSearchHash.ts b/src/frontend/src/utils/urlSearchHash.ts new file mode 100644 index 00000000..f6318862 --- /dev/null +++ b/src/frontend/src/utils/urlSearchHash.ts @@ -0,0 +1,62 @@ +import type { AdvancedFilterState, ContentType } from '../types'; + +export interface UrlSearchHashState { + /** + * Value of the active "Search By" target - the text input for general/direct/text + * fields, or the selected provider-field value. Serialized as `q` so a link + * round-trips back into whichever target `searchBy` names. + */ + queryValue: string | number | boolean; + searchBy: string; + contentType: ContentType; + combinedMode: boolean; + advancedFilters: Partial; +} + +const serializeQueryValue = (value: string | number | boolean): string => { + if (typeof value === 'boolean') { + return value ? '1' : ''; + } + return typeof value === 'number' ? String(value) : value; +}; + +/** + * Build the URL hash fragment (without the leading `#`) that mirrors the + * current search state, using the same param names parseUrlSearchParams reads. + * + * Kept as a hash fragment (not query params) so it stays browser-side only, + * rather than looking like a server-processed query string. + */ +export const buildUrlSearchHash = (state: UrlSearchHashState): string => { + const params = new URLSearchParams(); + + const queryValue = serializeQueryValue(state.queryValue); + if (queryValue) { + params.set('q', queryValue); + } + if (state.searchBy && state.searchBy !== 'general') { + params.set('search_by', state.searchBy); + } + if (state.combinedMode) { + params.set('content_type', 'combined'); + } else if (state.contentType && state.contentType !== 'ebook') { + // 'ebook' is the app's default content type - omit it like 'general' search_by, + // so a plain default-state URL doesn't carry a hash at all. + params.set('content_type', state.contentType); + } + + const { isbn, author, title, sort, content, lang, formats } = state.advancedFilters; + if (isbn) params.set('isbn', isbn); + if (author) params.set('author', author); + if (title) params.set('title', title); + if (sort) params.set('sort', sort); + if (content) params.set('content', content); + for (const value of lang ?? []) { + if (value) params.append('lang', value); + } + for (const value of formats ?? []) { + if (value) params.append('format', value); + } + + return params.toString(); +};