Deep-link Search By mode via URL hash (#1311)

Closes #1228.

Search By mode (General/Author/Title/Series/Manual), content type and
the search query now live in the URL as a hash fragment, updated live as
you search, not just parsed once on load. A shared/bookmarked link like
`#search_by=manual&q=foundation` reopens in that exact mode with the
query filled in.

Following the direction from the issue thread:
- Hash fragment instead of query string, so it stays browser side only.
- Live updates via `history.replaceState`, no history spam per
keystroke.
- Default Search By persisted in a client side cookie as fallback when
there's no hash, no user accounts needed.

Tested manually against a local build (search-by switching, hash live
update, deep link reload, cookie fallback when there's no hash, and hash
overriding a stale cookie).
This commit is contained in:
Nicholas Velten
2026-09-05 01:13:59 -04:00
committed by GitHub
parent 22aa59e567
commit 46d21cafbc
12 changed files with 692 additions and 59 deletions
+46 -23
View File
@@ -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
+68 -7
View File
@@ -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<boolean>(() =>
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<string | undefined>(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}
/>
</div>
@@ -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 ? (
<AdminSettingsWarmupMount key={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) ? (
<UrlSearchBootstrapMount
key={urlSearchNonce}
parsedParams={parsedParams}
config={config}
contentType={contentType}
combinedMode={combinedMode}
combinedModeAllowed={combinedModeAllowed}
queryTargets={queryTargets}
advancedFilters={advancedFilters}
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
resolvedMetadataSortOptions={resolvedMetadataSortOptions}
@@ -2766,6 +2826,7 @@ function App() {
setAdvancedFilters={setAdvancedFilters}
setShowAdvanced={setShowAdvanced}
setActiveQueryTarget={setActiveQueryTarget}
setSearchFieldValue={updateSearchFieldValue}
runSearchWithPolicyRefresh={runSearchWithPolicyRefresh}
onComplete={() => {
setHasExecutedUrlSearchBootstrap(true);
@@ -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<SetStateAction<AdvancedFilterState>>;
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<string, string | number | boolean>;
}) => 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,
});
});
+138 -11
View File
@@ -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);
});
}
@@ -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);
});
});
+35 -1
View File
@@ -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');
});
});
@@ -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<Storage>) => {
const store = new Map<string, string>();
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);
});
});
@@ -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);
});
});
+16 -3
View File
@@ -8,6 +8,12 @@ export interface ParsedUrlSearch {
advancedFilters: Partial<AdvancedFilterState>;
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,
};
+21
View File
@@ -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);
};
@@ -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
}
};
+62
View File
@@ -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<AdvancedFilterState>;
}
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();
};