Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions openlibrary/i18n/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -1023,29 +1023,30 @@ msgid "Solr Editions"
msgstr ""

#: design/toggle.html openlibrary/plugins/worksearch/code.py
#: search/availability_i18n.html work_search.html
#: search/availability_i18n.html type/author/view.html work_search.html
msgid "Readable Only"
msgstr ""

#: work_search.html
#: type/author/view.html work_search.html
msgid "Filter by availability"
msgstr ""

#: openlibrary/plugins/worksearch/code.py search/search_modal_i18n.html
#: type/edition/view.html type/work/view.html work_search.html
#: type/author/view.html type/edition/view.html type/work/view.html
#: work_search.html
msgid "Language"
msgstr ""

#: search/search_modal_i18n.html work_search.html
#: search/search_modal_i18n.html type/author/view.html work_search.html
msgid "Search languages…"
msgstr ""

#: books/edit/edition.html languages/index.html search/search_modal_i18n.html
#: work_search.html
#: type/author/view.html work_search.html
msgid "Languages"
msgstr ""

#: work_search.html
#: type/author/view.html work_search.html
msgid "Filter by language"
msgstr ""

Expand Down Expand Up @@ -1750,7 +1751,7 @@ msgstr ""
msgid "Search your reading log"
msgstr ""

#: account/reading_log.html type/author/view.html
#: account/reading_log.html
#, python-format
msgid "— Show <a href=\"%(url)s\">only ebooks</a>?"
msgstr ""
Expand Down Expand Up @@ -6907,9 +6908,13 @@ msgstr ""
msgid "Search %(author)s books"
msgstr ""

#: type/author/view.html
msgid "No matching works by this author with the current filters."
msgstr ""

#: type/author/view.html
#, python-format
msgid "Show <a href=\"%(url)s\">everything</a> by this author?"
msgid "Show all %(count)s works"
msgstr ""

#: RelatedSubjects.html SubjectTags.html openlibrary/plugins/worksearch/code.py
Expand Down
81 changes: 46 additions & 35 deletions openlibrary/plugins/openlibrary/js/SearchFilterBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@
* Wires the availability toggle + language filter popover on the search results
* page (openlibrary/templates/work_search.html). They render empty from the
* template; this module seeds them with the current selection, navigates to an
* updated /search URL when a filter changes, and keeps the cross-page
* sticky-filter state in sessionStorage so the header search modal and the
* search-page filters stay in sync.
* updated /search URL when a filter changes, and keeps the durable reading
* preference (availability + language) in localStorage so the header search
* modal, the search-page filters, and other listing surfaces stay in sync.
*
* Persistence model — URL is the source of truth on /search:
*
* - On init, if the URL carries any filter param (availability params or
* `language`), sessionStorage is mirrored from the URL. This way the modal
* `language`), the stored preference is mirrored from the URL. This way the modal
* will reflect a filter change made via the toggle, the language popover, or
* the sidebar language facet (which navigates the page with a new `language=`
* param) the next time it opens.
*
* - On init, if the URL carries NO filter params and sessionStorage has a
* non-default value, we replace-navigate to /search with those sticky
* - On init, if the URL carries NO filter params and the stored preference has
* a non-default value, we replace-navigate to /search with those sticky
* filters applied. This handles arriving at /search from a search box
* submit on another page or from `?q=foo` typed straight into the address
* bar — the user gets the filters they last set in this session.
* bar — the user gets the filters they last set, including on a prior visit.
*
* The full language catalogue is fetched lazily on first popover open.
*/
Expand All @@ -27,12 +27,11 @@ import {
AVAILABILITY_TO_PARAMS,
DEFAULT_AVAILABILITY,
DEFAULT_LANGUAGE_OPTIONS,
SS_AVAILABILITY_KEY,
SS_LANGUAGES_KEY,
ssGet,
ssSet,
availabilityFromParams,
readStoredAvailability,
readStoredLanguages,
writeStoredAvailability,
writeStoredLanguages,
} from './search-modal/constants.js';
import { fetchLanguageOptions } from './search-modal/languages.js';

Expand All @@ -43,14 +42,6 @@ const AVAILABILITY_PARAM_KEYS = [
...new Set(Object.values(AVAILABILITY_TO_PARAMS).flatMap(Object.keys)),
];

function writeStoredAvailability(value) {
ssSet(SS_AVAILABILITY_KEY, value || DEFAULT_AVAILABILITY);
}

function writeStoredLanguages(values) {
ssSet(SS_LANGUAGES_KEY, JSON.stringify(values || []));
}

// ── URL / sticky-filter helpers ────────────────────────────────────────────

function urlHasAnyFilterParam(params) {
Expand All @@ -60,26 +51,32 @@ function urlHasAnyFilterParam(params) {
}

/**
* Mirror the current URL's filter state to sessionStorage so the modal opens
* with the same selection next time. We always write both keys so removing a
* filter via the popovers/sidebar clears the stored value too.
* Mirror the current URL's filter state to the stored preference so the modal
* (and other surfaces) open with the same selection next time. We always write
* both keys so removing a filter via the popovers/sidebar clears the stored
* value too.
*/
function syncSessionStorageFromUrl(params) {
function syncStoredPrefFromUrl(params) {
writeStoredAvailability(availabilityFromParams(name => params.get(name)));
writeStoredLanguages(params.getAll('language'));
}

/**
* If the URL has no filter params at all and sessionStorage has a non-default
* value, replace-navigate to /search with the sticky filters applied. Returns
* true when a navigation was kicked off (caller should stop further init).
* If the URL has no filter params at all and the stored preference has a
* non-default value, replace-navigate to the current page with the sticky
* filters applied. Returns true when a navigation was kicked off (caller should
* stop further init).
*
* The reading preference is global, so this inherits it onto whatever listing
* page hosts the bar (/search, an author page, …) — the bar's own controls then
* render the inherited state visibly, which is what makes the stickiness safe.
*
* `replace` is used so the unfiltered URL doesn't end up in the back-stack.
*/
function maybeApplyStickyFilters(params) {
if (urlHasAnyFilterParam(params)) return false;

const storedAvail = ssGet(SS_AVAILABILITY_KEY) || DEFAULT_AVAILABILITY;
const storedAvail = readStoredAvailability();
const storedLangs = readStoredLanguages();
if (storedAvail === DEFAULT_AVAILABILITY && storedLangs.length === 0) {
return false;
Expand All @@ -89,20 +86,21 @@ function maybeApplyStickyFilters(params) {
const mapped = AVAILABILITY_TO_PARAMS[storedAvail] || {};
Object.entries(mapped).forEach(([key, value]) => next.set(key, value));
storedLangs.forEach(code => next.append('language', code));
window.location.replace(`/search?${next.toString()}`);
window.location.replace(`${window.location.pathname}?${next.toString()}`);
return true;
}

/**
* Navigate to /search with the current query string mutated by `mutate`.
* Pagination is reset because the result set changes.
* Navigate to the current page with its query string mutated by `mutate`.
* Pagination is reset because the result set changes. Uses the live pathname so
* the same bar drives /search and other listing surfaces (e.g. author pages).
* @param {(params: URLSearchParams) => void} mutate
*/
function navigateWithParams(mutate) {
const params = new URLSearchParams(window.location.search);
mutate(params);
params.delete('page');
window.location.assign(`/search?${params.toString()}`);
window.location.assign(`${window.location.pathname}?${params.toString()}`);
}

/**
Expand All @@ -118,10 +116,10 @@ export function initSearchFilterBar(container) {
// about to unload.
if (maybeApplyStickyFilters(currentParams)) return;

// URL is now the source of truth — mirror it into sessionStorage so the
// modal sees the same filters next time it opens. Has to run *before* the
// popovers are seeded so a stale sessionStorage doesn't leak into them.
syncSessionStorageFromUrl(currentParams);
// URL is now the source of truth — mirror it into the stored preference so
// the modal sees the same filters next time it opens. Has to run *before* the
// popovers are seeded so a stale stored value doesn't leak into them.
syncStoredPrefFromUrl(currentParams);

const availabilityEl = container.querySelector('ol-toggle');
const languageEl = container.querySelector('ol-select-popover');
Expand Down Expand Up @@ -173,4 +171,17 @@ export function initSearchFilterBar(container) {
});
});
}

// Empty-state escape hatch ("Show all N works"): the link already clears the
// filter params from the URL, but the stored global preference would be
// re-inherited on reload and zero the page again. Wipe the stored preference
// here so "show all" actually sticks; the link's own href then navigates to
// the unfiltered page.
const clearLink = document.querySelector('.js-clear-reading-prefs');
if (clearLink) {
clearLink.addEventListener('click', () => {
writeStoredAvailability(DEFAULT_AVAILABILITY);
writeStoredLanguages([]);
});
}
}
17 changes: 8 additions & 9 deletions openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@ import {
DEFAULT_AVAILABILITY,
DEFAULT_LANGUAGE_OPTIONS,
DEFAULT_SEARCH_MODAL_STRINGS,
SS_AVAILABILITY_KEY,
SS_LANGUAGES_KEY,
ssGet,
ssSet,
availabilityOptionsFromElement,
readableLanguageMismatch,
readableEditionLanguages,
readStoredAvailability,
readStoredLanguages,
writeStoredAvailability,
writeStoredLanguages,
searchModalStringsFromElement,
siteLanguageToMarc,
readRecentSearches,
Expand Down Expand Up @@ -899,7 +898,7 @@ export class SearchModal extends LitElement {
// explicit stored 'readable'; everything else — no preference, or a
// legacy 'open'/'borrowable' value from before the toggle — collapses to
// the default 'all' (toggle off).
const _storedAvailability = ssGet(SS_AVAILABILITY_KEY);
const _storedAvailability = readStoredAvailability();
this._availability = _storedAvailability === 'readable' ? 'readable' : DEFAULT_AVAILABILITY;
this._languages = readStoredLanguages();

Expand Down Expand Up @@ -1555,21 +1554,21 @@ export class SearchModal extends LitElement {

_onLanguagesChange(e) {
this._languages = [...e.detail.selected];
ssSet(SS_LANGUAGES_KEY, JSON.stringify(this._languages));
writeStoredLanguages(this._languages);
this._refetchIfActive();
}

_setAvailability(value) {
this._availability = value;
ssSet(SS_AVAILABILITY_KEY, value);
writeStoredAvailability(value);
this._refetchIfActive();
}

_clearAllFilters() {
this._availability = DEFAULT_AVAILABILITY;
this._languages = [];
ssSet(SS_AVAILABILITY_KEY, DEFAULT_AVAILABILITY);
ssSet(SS_LANGUAGES_KEY, JSON.stringify([]));
writeStoredAvailability(DEFAULT_AVAILABILITY);
writeStoredLanguages([]);
this._refetchIfActive();
}

Expand Down
68 changes: 55 additions & 13 deletions openlibrary/plugins/openlibrary/js/search-modal/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ export function searchModalStringsFromElement(el) {
* Shared by the header modal and the search-page filter row so both produce
* identical filters. The param names match WorkSearchScheme.facet_rewrites
* (`public_scan`, `print_disabled`, `has_fulltext`).
*
* Mirrored server-side in openlibrary/plugins/worksearch/code.py; the two copies
* are kept in lockstep by openlibrary/plugins/worksearch/tests/test_availability_sync.py.
*/
export const AVAILABILITY_TO_PARAMS = {
all: {},
Expand Down Expand Up @@ -340,30 +343,69 @@ export function readableEditionLanguages({ edition, languages, options }) {
}

/**
* sessionStorage keys for per-session filter persistence.
* Reading-preference storage keys.
*
* Availability and language are a durable, cross-session *reading preference* —
* "what I can read" and "what language I read in" — not per-search scope. They
* live in localStorage (survive a closed tab) and are shared by every listing
* surface that renders the filter controls: the header search modal, the
* /search filter row, and (Phase 1+) author and other listing pages. Facets
* (subject, publisher, year, …) are deliberately NOT stored here — they're
* scope, kept in the URL only.
*
* The keys are intentionally un-prefixed by surface ("reading-pref", not
* "header-search") because the value is global, owned by no single surface.
*/
export const SS_AVAILABILITY_KEY = 'ol-header-search-availability';
export const SS_LANGUAGES_KEY = 'ol-header-search-languages';
export const LS_AVAILABILITY_KEY = 'ol-reading-pref-availability';
export const LS_LANGUAGES_KEY = 'ol-reading-pref-languages';

/**
* sessionStorage read/write that swallow access errors (private browsing, quota,
* disabled storage). `ssGet` returns null on failure; `ssSet` is a no-op. Shared
* by the header modal and the search-page filter row so both persist filter
* state the same way.
* localStorage read/write that swallow access errors (private browsing, quota,
* disabled storage). `lsGet` returns null on failure; `lsSet` is a no-op.
*
* @param {string} key
* @returns {string|null}
*/
export function ssGet(key) {
try { return sessionStorage.getItem(key); } catch { return null; }
export function lsGet(key) {
try { return localStorage.getItem(key); } catch { return null; }
}

/**
* @param {string} key
* @param {string} value
*/
export function ssSet(key, value) {
try { sessionStorage.setItem(key, value); } catch { /* ignore */ }
export function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch { /* ignore */ }
}

/**
* Read the stored availability preference, falling back to DEFAULT_AVAILABILITY
* when unset or unreadable. The single read path shared by every surface.
*
* @returns {string}
*/
export function readStoredAvailability() {
return lsGet(LS_AVAILABILITY_KEY) || DEFAULT_AVAILABILITY;
}

/**
* Persist the availability preference. A falsy value is coerced to the default
* so an empty write clears the preference rather than storing "".
*
* @param {string} value
*/
export function writeStoredAvailability(value) {
lsSet(LS_AVAILABILITY_KEY, value || DEFAULT_AVAILABILITY);
}

/**
* Persist the language preference as a JSON array of MARC codes. The single
* write path shared by every surface; `readStoredLanguages` is its inverse.
*
* @param {string[]} values
*/
export function writeStoredLanguages(values) {
lsSet(LS_LANGUAGES_KEY, JSON.stringify(values || []));
}

/**
Expand Down Expand Up @@ -420,7 +462,7 @@ export function removeRecentSearch(query) {
}

/**
* Read the language list from sessionStorage. Guards against missing values,
* Read the language preference from localStorage. Guards against missing values,
* unparseable JSON, and values that parse to a non-array (e.g. a previously
* stored object or string), any of which would otherwise leave callers with a
* non-iterable or character-iterable value. Non-string entries are dropped so a
Expand All @@ -429,7 +471,7 @@ export function removeRecentSearch(query) {
* @returns {string[]}
*/
export function readStoredLanguages() {
const raw = ssGet(SS_LANGUAGES_KEY);
const raw = lsGet(LS_LANGUAGES_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
Expand Down
Loading