Surface Search Inside: modal band, wider rescue net, filterable /search/inside - #13429
Surface Search Inside: modal band, wider rescue net, filterable /search/inside#13429lokesh wants to merge 31 commits into
Conversation
… fixes - FullTextSuggestionsPartial fetched 100 hits and rendered 4; fetch 10. Every hit costs availability + Infobase hydration, so this is ~10x cheaper per suggestion render. - /search/inside.json gains a facets param (default true, preserving the API) so lightweight callers can skip the aggregations work upstream. - fulltext_search_async: skip the hydration pipeline entirely on zero-hit responses (the truthy-dict check ran it for nothing), and key edition attachment by ocaid instead of ocaids.index() — index() only finds the first occurrence, so the second of two hits sharing an ocaid got no edition and was silently dropped by the templates. - Tests for the new facets param, the dedupe fix, and the (previously failing) empty-hit path. Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
The lazily-loaded Search Inside suggestion module previously rendered only when Solr returned zero docs. Now: - Weak results (num_found <= 3): the module renders after the results list — metadata search barely matched, so check inside the books too. - ?debug_fulltext=true: forces the module above the results list on any query, as a debug switch for evaluating a blended results page before any rollout decision. The module stays a lazy client fetch of the cached (300s) partial, so server render never waits on the fulltext backend. The automatic-on- every-page variant of this was rolled back 2020-03-26 for load reasons; this stays bounded to queries metadata search is failing. Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
Search Inside snippet matches now render in the header search modal,
after the Solr top results — and as the rescue when Solr finds nothing.
Serif italic quotes with the match marked, a title/author/page
attribution line, and a "See all N Search Inside matches" link into
/search/inside. Each row deep-links to the passage in BookReader via
the #search/ anchor.
Kept deliberately cheap and secondary:
- Its own 800ms debounce (vs 400ms for Solr) and race key; the fulltext
response never gates the instant results.
- limit=3, facets=false against /search/inside.json.
- No spinner and no error state: the band renders only when a response
with hits lands, and failures stay silent.
Snippet {{{match}}} markers are parsed into segments client-side
(fulltext.js) and rendered through Lit text bindings — API text never
goes through innerHTML. Hits without a hydrated OL edition fall back to
the scan metadata instead of being dropped.
Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
- Language facet sidebar ("Zoom In"), built from the FTS response's
top-languages aggregation — the JSON API always requested these
aggregations; the HTML page now renders them. Selecting a language
reruns the search with an ANDed languageSorter:"..." Lucene clause
(build_fulltext_query, unit-tested); a selected chip clears it and a
hidden form input carries it through re-searches.
- Every result now shows "Jump to page N" links that open BookReader at
the exact page of the match (details/{ia}/page/{n}?q=...), alongside
the existing passage link.
- FulltextResults pagination uses the passed results_per_page instead
of a hardcoded 20 that silently duplicated RESULTS_PER_PAGE.
- messages.pot regenerated for the new strings across the branch.
Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
/search/inside gets the same filter row as /search — a Readable Only
toggle (with a live count of readable matches) and a multi-select
language popover — wired by SearchFilterBar through a per-surface param
dialect: this page speaks readable=true and repeated language codes
instead of Solr's availability params. The sticky-filter sessionStorage
is shared with /search and the header modal, so a selection made
anywhere follows the patron across surfaces.
Server-side:
- build_fulltext_query moves to core/fulltext.py and grows multi-
language OR clauses plus a readable collection clause (inlibrary, or
not printdisabled-only), mirroring core.lending's heuristic.
- /search/inside.json accepts readable and language params. Language
values may be MARC codes ("fre") or languageSorter names ("French");
language_name_maps() resolves both directions, so generated URLs keep
codes while the FTS clause uses the names the index stores.
- The Readable Only toggle's count runs concurrently with the main
search (asyncio.gather) when the filter is off.
FulltextResults no longer drops hits whose scan didn't hydrate to an OL
edition — they degrade to the scan's own IA metadata row, so totals and
pagination stay honest.
Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
The "Found inside books" band fired /search/inside.json for every settled query, so a clean title lookup paid an external-backend round trip to render OCR noise under a perfect match. Fulltext now fetches only when it's likely to earn its place: - Passage-shaped queries — a quoted phrase, a trailing "?", or 5+ words (isPassageQuery) — fetch immediately on the existing 800ms debounce. These are the queries metadata search answers worst. - Everything else waits for the Solr response and fetches only when it looks weak (solrLooksWeak): no docs, or no title/author overlap with the query in the top 3 docs (word-boundary prefix match, diacritics folded — "gats" matches Gatsby, "art" doesn't match Bartleby, and a misspelling like "hobit" reads as weak). Strong results clear the band instead, so a title match is never trailed by a stale band. - A failed Solr fetch also fires the rescue — fulltext runs on a separate backend. Both helpers are pure and unit-tested. Also in the band: - The modal's readable/language filters ride along on band fetches and the see-all link (_appendFulltextFilterParams). - The empty state reads "No matching books or authors" when the band has hits — "No results found" above visible results contradicted itself. - Quieter styling (no serif italic, no mark highlight) and a static "See more Search Inside matches" footer label. Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
There was a problem hiding this comment.
Found 2 issues across 2 rules (1 WCAG, 1 Best Practice).
Reviewed by AccessLint, which checks every pull request for accessibility issues.
When a search settles on zero catalog hits, "See results" promised results that aren't there. Swap in "Go to full search" — the button still usefully leads to /search (facets, advanced search, and the Search Inside rescue band). Pre-search, mid-fetch, and fetch-error states keep the neutral "See results" label. Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
# Conflicts: # openlibrary/i18n/messages.pot # openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js
Swap the modal's primitive palette tokens (--darker-grey, --white, --lightest-grey) for their semantic equivalents: --color-text for body and title ink, --color-text-muted for the Clear all button, --color-surface for chrome backgrounds, --color-control-hover for hover/focus row states, and --color-surface-sunken for cover/avatar placeholders. Three deliberate primitives stay: the row hairline (fainter than any border token), the remove-recent hover step, and the spinner's white ring over a darkened cover. Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
The FTS endpoint parses `q` two ways, and the choice decides whether `olonly=true` counts. A plain query is matched as text and olonly is applied; any field: clause switches it to a Lucene parse where olonly is silently ignored and the search covers all of archive.org. The readable clause we appended also spelled negation `!`, which that parser doesn't support, so the whole query fell back to text matching and searched the books for the words in the filter — for "golum" it cut 2,706 hits to 39 while raising the share of print-disabled-only results. Readability now filters the fetched hits instead, on the availability data the request already loads (is_readable / is_lendable, the same test the edition page uses), so `q` stays bare and olonly keeps working. Filtering runs before edition hydration, so dropped hits cost no Infobase lookup, and a filtered page can render fewer than 20 rows: the results line drops its "1 - 20 of" range when the filter is on, and a page that filters to empty says so. The toggle's count goes with the second query that produced it, which was parsed differently and ran over a different corpus. The modal's snippet band asks for extra rows when the filter is on so it doesn't thin out to one. Riding along: snippets deep-link to the page they were found on, result rows carry the publication year, and the filter chips give way to the toggle and popover that already hold the same state. Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
There was a problem hiding this comment.
Found 1 issue across 1 rule.
Reviewed by AccessLint, which checks every pull request for accessibility issues.
A language filter has to go into `q` as a `languageSorter:` clause, and any field clause flips the FTS endpoint to its Lucene parser, where `olonly=true` is accepted and ignored — so a language-filtered search quietly covers all of archive.org, unlinked Google scans and magazines included. `fulltext_search_async` now builds the query itself, notices when a clause was added, and drops the hits whose scan has no OL edition (`filter_ol_linked`). Hydration already looked every ocaid up, so the filter costs nothing extra. As with the readable filter, `total` still counts the dropped hits: the results line shows the total on its own rather than a range that would skip numbers between pages, and a page that filters to empty says so. Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
There was a problem hiding this comment.
Found 1 issue across 1 rule.
Reviewed by AccessLint, which checks every pull request for accessibility issues.
The endpoint now hands `readable` and `languages` to fulltext_search_async instead of pre-baking them into `q`, so the two call-signature assertions no longer matched. Adds a third case covering the pass-through, with the language catalogue lookup stubbed since it needs a site context the FastAPI tests don't have. Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf
Resolves SearchFilterBar.js by keeping both the per-surface param dialects and the new facet-count loading, with counts gated to Solr surfaces (/search/facets.json would misdescribe the FTS results on /search/inside). Carries the new loading-label into inside.html's popover, and bumps the page-plain.css budget the branch's shared filter-row CSS pushed 114B over. Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
Same fix as internetarchive#13469, carried here so /search/inside doesn't show title scrollbars until that lands: Literata's glyph metrics overrun the h3's 1.35 line-height by ~1px, and the LESS-era overflow: auto turns that into a scrollbar on every single-line title. Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
# Conflicts: # openlibrary/i18n/messages.pot
The FTS endpoint has two parse modes, and it reports which one it used in `fts-api.query_type`. A bare `q` is parsed as `dsl`, where the service builds the query from the user's words plus the request params. Any `field:` clause flips it to `lucene`, which hands the string straight to the engine and skips that builder — so every param the builder would have contributed silently disappears, `olonly` among them. Injecting `languageSorter:"German"` into `q` therefore widened the corpus from OL's catalogue to all of archive.org, which we were clawing back by dropping the unlinked hits after the fetch. IA's Gio pointed out the supported route: a `lang` request param that filters on the same normalized languageSorter field without touching `q`. Measured against prod, `lang=German` returns the same 923 hits as the field clause did, and `olonly` keeps applying on top of it, narrowing to 167. So `q` now goes out verbatim and the language rides along as `lang`, which lets build_fulltext_query, filter_ol_linked and the olonly-bypass branch all go away. `lang` takes one language — `lang=a,b` returns nothing and a repeated param keeps only the first — so both handlers narrow to the first selection and the popover acts as a radio group on this surface, with its heading saying so. /search keeps its multi-select. Readable-only is unchanged; it still post-filters on availability.
The full-text response envelope was being re-parsed in six places — the
search module, two page templates, two suggestion macros and the partials
handler — each with its own defaults and its own `fields.get(x, [''])[0]`
indexing, which raises IndexError on a field that is present but empty.
fulltext_page() now normalizes a response into (rows, total) once, and
FulltextRow/Snippet is what every server-side consumer takes. Falling out
of that:
- Snippet.html escapes each segment in one place, replacing the per-macro
replace() chains that turned < and > into guillemets and left & alone.
- page_num is read where it can be described honestly. Measured against
the live service it reports one page per hit while returning several
highlights, so the first snippet deep-links and the rest fall back —
the previous comment claimed a per-highlight alignment the API doesn't
have. Extra pages still pair positionally if that ever changes.
Empty pages are decided in the handler. empty_reason() returns one of
no_matches / filtered_out / past_end, and the template renders it. The
four template conditionals it replaces had a hole: paginating past the
end of an unfiltered result set matched none of them and printed a
backwards range ("321 - 320 of about 315 results") over an empty list
with no explanation.
resolve_language() replaces two different implementations of the same
resolution — a 15-line dedup loop in the web.py handler and a two-line
comprehension in the FastAPI one — and is now the single place a
multi-language request is narrowed to the one the backend's `lang` param
can apply. fulltext_search_async takes `language: str` rather than a list,
so that invariant is in the type. normalize_language_name and its unused
optional-map argument are gone; language_name_maps is cached.
Also drops search_time, which nothing has rendered since the results line
was rewritten.
The band's state was threaded through eight touchpoints of SearchModal:
four call sites that had to remember to pair _debouncedFtFetch() with
_debouncedFetch(), two clear paths, and the gating decision inlined in the
Solr response handler. Every future trigger carried the same obligation to
call both, which is the shape that rots.
FulltextBand owns the state, the fetch, the race key and the decision of
whether to call the backend at all. SearchModal reports what happened —
queryChanged / solrSettled / solrFailed — and mirrors {hits, total} into
reactive state. This follows the existing search-modal/ layout, where
authorSuggestion.js, searchFacets.js and languages.js are modules the
component calls; the pure gating helpers were already split out into
fulltext.js, and this is the stateful half that wasn't.
The four paired call sites collapse into _scheduleSearch(), so the metadata
request and the band can't drift apart.
Two fixes come with it:
- queryChanged() invalidates the in-flight fetch. Without that, a band
fetched for the previous query could land during the 800ms passage
debounce and paint under the edited one.
- clear() no longer notifies when the band is already empty, so the common
keystroke path stops churning a re-render.
fulltextSearchParams() is shared by the fetch and the "see more" link so
they can't disagree, and narrows the language list to one — the handler
applies one either way, so sending more made the link promise a filter
that was never applied. It replaces _appendFulltextFilterParams, which
duplicated the language half of _appendFilterParams.
Net: SearchModal.js 1972 -> 1911 lines.
There was a problem hiding this comment.
Found 2 issues across 2 rules.
Reviewed by AccessLint, which checks every pull request for accessibility issues.
The sync async_bridge.wrap shim went away with the /search/inside rewrite; no test requested this fixture, so it sat pointing at a symbol that no longer exists.
A question mark is no longer a passage signal: short interrogatives are
usually titles ("Where's Waldo?"), and a question Solr answers badly is
already rescued by the weak path. To make that rescue reliable,
solrLooksWeak now judges each doc by what the modal would render for it
(title, subtitle, promoted edition title, authors — so "kammer" hitting
the German edition reads as answered), matches prefixes in both
directions so "hobbits" matches The Hobbit, and treats interrogatives
as stopwords so "how do birds navigate?" can't be false-answered by a
"How to..." title.
IA confirmed the cross-document FTS engine has no knowledge of a
document's pages — page-accurate positions exist only in the per-document
engine BookReader queries itself. So stop pretending otherwise: remove
the page_num plumbing (Snippet.page, FulltextRow.pages) and the /search
module's "Page: n" label (already a dead non-link in production, showing
a number the engine can't stand behind), and link every snippet to
BookReader with the query — /details/{id}?q=... in the modern form,
replacing the legacy /stream #search anchors — so its own in-book search
finds and highlights the passage. The /search module's quote link gains
the query it never carried.
for more information, see https://pre-commit.ci
The band at the foot of /search now renders the same rows as
/search/inside — SearchResultsWork plus a new FulltextResultIA macro for
scans with no OL edition — under a "Found inside books" heading,
skipping books the metadata results already list (a new exclude param
through both partial handlers) and collapsing to a lone see-all button
when every hit was excluded. The old suggestion-item macros and their
CSS are gone.
Queries to the FTS backend are normalized to one quoted phrase
(phrase_query): bare words match anywhere in a book, and the backend
mishandles stray or curly quotes, so quotes are stripped and the whole
query re-wrapped. An emptied query skips the upstream call.
In the search modal, the band's see-all leaves the heading for the
footer bar: a secondary ol-button ("23,783 inside books") opposite the
primary see-results button, with the full sentence as its accessible
name. compose.yaml also passes OL_COVERSTORE_PUBLIC_URL to fast_web.
There was a problem hiding this comment.
Found 6 issues across 4 rules (5 WCAG, 1 Best Practice).
Reviewed by AccessLint, which checks every pull request for accessibility issues.
for more information, see https://pre-commit.ci
The FTS request already went to the backend as one quoted phrase, but
every BookReader link built from its hits — the modal's snippet rows,
FulltextSnippet, FulltextResultIA — passed the raw query, so the
in-book search matched each word ("the lower haight" → 515 hits of
"the"). phrase_query is now a template global, and the modal mirrors
it client-side (phraseQuery in fulltext.js), so the passage the hit
was found by is what BookReader searches for.
The band's see-all buttons also read "Found in N books" instead of
"N inside books" / "See all N matches inside books", pluralized via
ungettext on the server-rendered band.
There was a problem hiding this comment.
Found 2 issues across 2 rules.
Reviewed by AccessLint, which checks every pull request for accessibility issues.
The search modal's fulltext band, facet-count loading, and shared helpers are statically imported via ol.js, adding ~3.4KB gzip of site-wide chrome to all.js (155.1KB vs the 155KB cap). The rebuilt quote-card CSS rides inside legacy.css, tipping page-edit.css 191B over its 27KB cap while form/plain had headroom.
There was a problem hiding this comment.
Found 2 issues across 2 rules.
Reviewed by AccessLint, which checks every pull request for accessibility issues.
- Dedupe the modal's fulltext band against catalog rows at render time (mirrors /search's exclude), trimmed to FULLTEXT_LIMIT. - Two-tier footer button labels: wide exact counts, narrow compact ones (Intl compact notation) swapped by media query; aria-label stays wide. - Loading spinners on the fulltext see-all and fulltext hit rows during navigation; OLButton keeps a loading link's href so the navigation the spinner is for isn't cancelled, blocking re-activation in the click listener instead. - SearchResultsWork/FulltextResultIA: rename extra -> extra_row and move it to a full-width row under the details/CTA columns. - Trim inline snippets to 2 per hit; bump snippet font size.
# Conflicts: # openlibrary/plugins/openlibrary/js/search-modal/SearchModal.js
There was a problem hiding this comment.
Found 2 issues across 2 rules.
Reviewed by AccessLint, which checks every pull request for accessibility issues.
…ders The search page's Search-inside button now carries the same short visible label as the modal's, with the full "See all matches found in N books" sentence moved to aria-label. The modal's own button drops its trailing arrow icon.
There was a problem hiding this comment.
Found 5 issues across 4 rules (4 WCAG, 1 Best Practice).
Reviewed by AccessLint, which checks every pull request for accessibility issues.
Feature. Search Inside — full-text search across every scanned page — is Open Library's most differentiated feature and one of its least discoverable.
It's the planned follow-up to the header search modal launch (#12797) a couple of months back. The modal made the header the place patrons start a search from; putting full-text hits inside it was always the next step, and the rest of this PR brings the surfaces it feeds — /search and /search/inside — up to the same bar.
What changes
/searchnum_found <= 3or the query is passage-shaped, skipping books the page already lists/search/insideA "Found inside books" band in the header search modal. Up to three snippet rows with the match marked, under the regular results, skipping any book the modal already lists above them. It doubles as the rescue when Solr comes back empty or weak, so a patron who half-remembers a line finds the book instead of a dead end. The way to the rest lives in the modal's footer bar: a secondary "23,783 inside books" button opposite the primary see-results button, carrying the band's filters into /search/inside.
When the band fetches. Two tests, in sequence, and most queries never reach the fulltext service at all:
the secret gardensecret/garden→ answeredgatsbyhobithobit→ weakqwertyuiop"it was the best of times""Weak" in the modal is a term-overlap check, not a count. Overlap is judged against the top 3 docs' title and author words: query words of 3+ letters, common words dropped, diacritics folded, matched as word-boundary prefixes — so
gatsbymatches Gatsby butartdoesn't match Bartleby, and a misspelling finds no overlap. A query with no meaningful words left to test counts as answered. On/searchthe gate is the same shape: the blunt count (num_found <= 3, widened from exactly zero) or a passage-shaped query, mirroring the modal.The /search band is the /search/inside page in miniature. The old suggestion module — its own row markup, its own snippet style — is gone. The band renders up to three of the same rows /search/inside renders (minus their lending/list controls) under the same "Found inside books" heading, with one primary see-all button. It skips any scan the metadata results above it already list (the page passes their IA ids to the partial), and when every hit was already on the page it collapses to just the see-all button — a pointer, not a duplicate.
Every query reaches the FTS service as one quoted phrase. Bare words match anywhere in a book — 1.5M hits for
it was the best of times, 14K as a phrase — and these surfaces exist for passage lookups. Measured against the backend: an unbalanced quote silently degrades to a bare-word search, an inner quote splits the phrase into fragments, and backslash escaping does nothing — so user-typed quotes (straight or curly) are stripped and the whole query sent as one phrase. A query that's nothing but quotes skips the upstream call entirely./search/inside as a real results page. A Readable Only toggle and a language filter, publication years on rows, and filter selections that stay sticky across /search, /search/inside and the modal. Both filters are off by default. Every quote card opens BookReader with the query, so its in-book search finds and highlights the passage — the FTS index has no page knowledge (per IA), so there's no page number to deep-link and BookReader's own search is the most precise landing we can offer.
Rows that used to vanish. This is the biggest single change to what a patron sees on the existing page.
What they didn't see before. A hit only rendered if we could find an OL edition whose record names the scan, via its
ocaidfield. Plenty of our records don't name it — the link exists only in the other direction, asopenlibrary_editionin the scan's IA metadata — so the row was silently dropped while the count and the pagination went on counting it. Measured across 240 hits on production, that's about 10% of every page: 15–20 rows rendered per page of 20, under a header claiming thousands. Searchgollumon production today and The Lord of the Rings (the 1974 three-in-one,lordofrings3volu0000unse_l1t3) is not among the results, even though we hold the edition (OL52983076M), IA holds the scan, and the match is on page 1086 of it.What they'll see now. Those rows render, from the scan's own IA metadata — cover, title, year, authors and the matching passages — via a
FulltextResultIArow shared by /search/inside and the /search band. They're ordinary catalogued books, not stray uploads: of 21 dropped hits traced back to IA, all 21 pointed at a live OL edition. The one thing they don't get is a link to that edition page: the FTS response carries no field with the OL key, so the row links into the scan on archive.org instead. Recovering the key would mean a metadata call per hit, which isn't worth it to relabel a link.How the filters work
Language travels as the
lang=request param, never as a clause inq. AlanguageSorter:"German"clause flips the FTS service into lucene mode, where it drops ourolonly=trueflag (only scans that map to an OL edition) and widens results to all of archive.org.lang=filters the same normalized field while leavingquntouched, soolonlykeeps applying on top and counts stay right. It takes one language at a time, so the popover acts as a radio group on this surface, with its heading saying so; /search keeps its multi-select.Readable Only is applied to the hits we already fetched, using the availability data the rows need anyway —
is_readable(public domain) oris_lendable(borrowable), the same test the edition page uses. If the availability lookup fails wholesale we keep everything rather than empty the page.Keeping the load bounded
The 2020 rollback was a load story, so every new surface is gated or cheaper than what it replaces:
num_found <= 3or passage-shaped).Net new backend traffic is the modal band plus the passage-shaped slice of /search searches.
?debug_fulltext=trueforces the /search band on for evaluation.Testing
pytest openlibrary/plugins/inside/ openlibrary/tests/core/test_fulltext.py openlibrary/tests/fastapi/test_search.py openlibrary/core/fulltext.py openlibrary/plugins/inside/code.py --doctest-modules— 157 passing, including coverage that the query goes out as one normalized phrase witholonlyintact and that a language becomes thelangparam. Full jest suite passing (780).olonlybypass, thelangfix and the quote-handling quirks behind phrase normalization are each reproducible with a one-line curl, and every count above came from those runs./search/inside.jsoncall and shows no band; a 6-word query fetches via the passage path; a junk query with zero Solr docs fetches as the rescue;"it was the best of times"renders three highlighted snippets; and /search/inside round-tripsreadable/languageand its empty, short-page and past-the-end states.Two caveats for local testing. Dev's mock availability service returns
{}, so Readable Only fails open and looks inert; it was exercised against a mock answering from Solr'sebook_access. And the dev database has no editions for the scans the FTS service returns, so every row takes the unlinked-scan path — which now renders (the sharedFulltextResultIArow) instead of leaving the band empty; the hydrated-row path is covered by tests rather than by the dev stack.Screenshot
Screenshots showing a range of search result possibilities (from an earlier revision — the band chrome has since been restyled, and the modal's see-all now lives in the footer):
Notes for IA
Thanks to @Gio for pointing us at
lang=. Three things would still simplify this:langtake multiple languages.lang=German,Frenchreturns nothing and a repeated param keeps only the first, which is the only reason /search/inside filters by one language while /search allows several."degrades the whole query to bare-word matching and an inner quote splits the phrase; we now normalize quotes away client-side, but backend escaping (or rejecting) would be sturdier.An access field (public / borrowable / restricted) would also let the readable filter run server-side. The
collectionvalues are the only signal available today, andinlibraryis a subset ofprintdisabled, so "readable" has no positive form.Stakeholders
@mekarpeles @cdrini @jimchamp