Skip to content

Surface Search Inside: modal band, wider rescue net, filterable /search/inside - #13429

Open
lokesh wants to merge 31 commits into
internetarchive:masterfrom
lokesh:search-inside
Open

Surface Search Inside: modal band, wider rescue net, filterable /search/inside#13429
lokesh wants to merge 31 commits into
internetarchive:masterfrom
lokesh:search-inside

Conversation

@lokesh

@lokesh lokesh commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

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

Surface Production today This PR
Header search modal Never A "Found inside books" band, up to 3 snippet rows — only when the query is passage-shaped, or when Solr's answer looks weak (see what weak means below) — with a see-all button in the modal footer
/search Lazy suggestion module, only at exactly zero Solr results A "Found inside books" band built from /search/inside's own result rows, whenever num_found <= 3 or the query is passage-shaped, skipping books the page already lists
/search/inside Results page that silently drops rows, no filters Every hit renders; Readable Only + language filters; publication years on rows; every quote links into BookReader's in-book search

A "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:

Query What decides it Fulltext request
the secret garden Not passage-shaped; Solr's top docs match on secret/garden → answered none
gatsby Solr returns The Great Gatsby → overlap → answered none
hobit Solr answers with something, but no top-3 title or author begins with hobit → weak after Solr
qwertyuiop Solr returns nothing → weak after Solr
"it was the best of times" Quoted phrase → passage-shaped, decided before Solr answers on the debounce

"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 gatsby matches Gatsby but art doesn't match Bartleby, and a misspelling finds no overlap. A query with no meaningful words left to test counts as answered. On /search the 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 ocaid field. Plenty of our records don't name it — the link exists only in the other direction, as openlibrary_edition in 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. Search gollum on 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 FulltextResultIA row 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 in q. A languageSorter:"German" clause flips the FTS service into lucene mode, where it drops our olonly=true flag (only scans that map to an OL edition) and widens results to all of archive.org. lang= filters the same normalized field while leaving q untouched, so olonly keeps 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) or is_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:

  • The modal never fetches per keystroke. Passage-shaped queries (a quoted phrase, or 5+ words) fetch after an 800ms debounce — exactly the queries metadata search answers worst. Everything else waits for Solr and fetches only if the answer looks weak, so a clean title lookup issues no fulltext request at all. It asks for 3 hits (9 with Readable Only on), not a page of 20.
  • The /search band fetches 10 instead of 100, which more than pays for the wider trigger (num_found <= 3 or passage-shaped).
  • Neither filter adds a request. Language rides along on the search itself; Readable Only reuses availability data already loaded.
  • An emptied query never leaves the building. Phrase normalization that strips a query to nothing short-circuits with zero hits instead of asking the backend.

Net new backend traffic is the modal band plus the passage-shaped slice of /search searches. ?debug_fulltext=true forces the /search band on for evaluation.

Testing

  • Python, in Docker: 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 with olonly intact and that a language becomes the lang param. Full jest suite passing (780).
  • The endpoint's behaviour was characterized against the live FTS service: the two parse modes, the olonly bypass, the lang fix and the quote-handling quirks behind phrase normalization are each reproducible with a one-line curl, and every count above came from those runs.
  • Manual against the dev stack with network inspection: "the secret garden" issues no /search/inside.json call 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-trips readable/language and 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's ebook_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 shared FulltextResultIA row) 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):

Screenshot 2026-09-01 at 3 43 23 PM Screenshot 2026-09-01 at 3 54 22 PM

Notes for IA

Thanks to @Gio for pointing us at lang=. Three things would still simplify this:

  1. Let lang take multiple languages. lang=German,French returns nothing and a repeated param keeps only the first, which is the only reason /search/inside filters by one language while /search allows several.
  2. A page number per highlight. The index behind this endpoint has no page knowledge (as discussed), so every quote now opens BookReader's own search on the query — which works, but lands on its first match rather than the quoted one. Per-highlight page numbers would let each quote deep-link to its page.
  3. Phrase parsing that survives a stray quote. An unbalanced " 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 collection values are the only signal available today, and inlibrary is a subset of printdisabled, so "readable" has no positive form.

Stakeholders

@mekarpeles @cdrini @jimchamp

lokesh added 6 commits August 25, 2026 00:05
… 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

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues across 2 rules (1 WCAG, 1 Best Practice).

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResults.html Outdated
Comment thread openlibrary/templates/search/inside.html Outdated
lokesh added 5 commits August 25, 2026 11:03
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

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 issue across 1 rule.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResults.html Outdated
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

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 issue across 1 rule.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResults.html Outdated
lokesh added 3 commits August 26, 2026 00:38
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
lokesh added 4 commits August 28, 2026 22:22
# 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.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues across 2 rules.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResults.html Outdated
Comment thread openlibrary/macros/FulltextResults.html Outdated
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.
@lokesh
lokesh marked this pull request as ready for review August 31, 2026 21:58
@lokesh lokesh added Needs: Review This issue/PR needs to be reviewed in order to be closed or merged (see comments). [managed] On Testing labels Aug 31, 2026
@lokesh
lokesh requested a review from mekarpeles August 31, 2026 22:25
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.
lokesh and others added 4 commits August 31, 2026 15:27
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.
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.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 6 issues across 4 rules (5 WCAG, 1 Best Practice).

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResultIA.html
Comment thread openlibrary/macros/FulltextResults.html
Comment thread openlibrary/macros/FulltextSearchSuggestion.html
Comment thread openlibrary/macros/FulltextSearchSuggestion.html
Comment thread openlibrary/macros/FulltextSearchSuggestion.html
Comment thread openlibrary/macros/FulltextSnippet.html
pre-commit-ci Bot and others added 2 commits September 1, 2026 19:00
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.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues across 2 rules.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResultIA.html
Comment thread openlibrary/macros/FulltextSnippet.html
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.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues across 2 rules.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResultIA.html
Comment thread openlibrary/macros/FulltextSnippet.html
- 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

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues across 2 rules.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResultIA.html
Comment thread openlibrary/macros/FulltextSnippet.html
…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.

@accesslint accesslint Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 5 issues across 4 rules (4 WCAG, 1 Best Practice).

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/macros/FulltextResultIA.html
Comment thread openlibrary/macros/FulltextSearchSuggestion.html
Comment thread openlibrary/macros/FulltextSearchSuggestion.html
Comment thread openlibrary/macros/FulltextSearchSuggestion.html
Comment thread openlibrary/macros/FulltextSnippet.html
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review This issue/PR needs to be reviewed in order to be closed or merged (see comments). [managed] On Testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant