Skip to content

Take search results to the Combined view when the match is there#761

Open
hoyla wants to merge 4 commits into
mainfrom
ljh-760-search-to-combined-view
Open

Take search results to the Combined view when the match is there#761
hoyla wants to merge 4 commits into
mainfrom
ljh-760-search-to-combined-view

Conversation

@hoyla

@hoyla hoyla commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

This change means that when you click a Giant search result for a paged document, it opens in the Combined view with the match highlighted — provided the match is actually reachable in Combined. Previously all search results took users to the Text or OCR view even when Combined was available.

Fixes #760. Background discussion in #733. Most of the the 350 lines are tests and comments rather than actual code changes.

Why

Elasticsearch has no concept of the "Combined" view — it's assembled in the frontend from the page index. Search picks the document field with the most highlights (text, ocr.english, etc) and forces that view via ?view=, which suppressed the Combined default and dropped users into flat text/OCR even when Combined would show the same match in the view we actually want people to use.

How

The viewer needs to know, before choosing a view, whether the search query is reachable in the page index. Elasticsearch can't say during search itself, so the answer is folded into a request the viewer already makes:

Backend. GET /api/pages2/:uri/pageCount accepts an optional q and returns searchMatchesInPages alongside the count — true/false when a query was sent, null otherwise. It is answered by Pages2.hasSearchMatch: a single Elasticsearch count query built with the same query builder as the page highlighters, so quoting and stemming semantics match exactly what the Combined view will highlight. A failed or unparseable query recovers to "no match" — opening a document can never break because of q. (Deliberately not answered via the existing /pages2/:uri/search endpoint, which fans out per hit page — permission check, ES multi-search, S3 PDF download, PDFBox geometry extraction — far too heavy to use as a boolean.)

Frontend. All decision inputs arrive in that one response, so the view is decided exactly once, inside the pageCount fetch callback, by a pure unit-tested helper (resolveInitialPagedView):

  • paged document, no view → Combined (the existing default, e.g. opening from a workspace)
  • paged document, search-forced text/OCR view, match reachable in the pages → Combined
  • anything else → honour the current view

Nothing re-fires when the view changes, so a user switching to the Text/OCR tab afterwards is never overridden. The switch is dispatched before the page count is stored, so no frame ever renders in the view being left. It goes through setResourceView, which the middleware syncs to the URL via replace — no back-button trap; after a search click the URL settles on ?view=combined&q=….

The match-driven rule self-heals the cases where page text is not a superset of the document fields — lossy Tesseract-era OCR, cross-field matches, any future translation view: those answer "no match", so the search-forced flat view is kept and the user still lands on a real match.

Behaviour

Open via Result
Search hit in body of a paged PDF (?view=text/ocr) Combined, match highlighted
Search hit only reachable outside the pages (lossy OCR / cross-field / future translation) forced flat view, as before
Search hit in a non-paged doc (email, plain text, image-only) forced view, as before
Opened from workspace/dataset (no ?view=) Combined default, as before
User clicks Text tab after landing on Combined stays on Text

Robustness

  • PageViewerOrFallback is not remounted when the /viewer/:uri route param changes, so the cached page count is tagged with the uri it was fetched for and a mismatched count reads as "not yet known". Without this, stepping to the next/previous result imposed Combined on a non-paged document using the previous document's count, and 404'd.
  • The pageCount fetch has a cancellation guard: a late response from a superseded request (stepping quickly between results) can neither replace the current document's data nor dispatch a view change after the document has been left.
  • useHighlightNavigation catches fetch failures, so a pages 404 can never surface as an uncaught runtime error.
  • Deploy skew: a backend without the parameter simply omits the field, which resolves to keeping the search-forced view — the pre-existing behaviour.

A note on shared URLs

A shared ?view=text with no query is honoured. A shared ?view=text&q=… resolves by match reachability — it's indistinguishable from a fresh search click. This is an accepted trade-off: a view+q URL never preserved the sharer's intent anyway (search navigation always focuses the first match, so it lands on match number 1 in any view). Directing a colleague to a specific passage is better addressed by snippet/selection sharing, which encodes an anchor rather than re-running a query.

OCR engines

Under the OcrMyPdf engine, page text retains the born-digital layer and adds OCR, so a text/ocr match is almost always also in the pages → answer "yes". Under the Tesseract engine, page text is a lossy OCR re-derivation, so a born-digital text match can be missing from the pages → answer "no" → the forced flat view is kept. The fix is correct under either engine; the per-document answer is what makes it robust.

Testing

  • resolveInitialPagedView: 11 unit tests, green.
  • ElasticsearchPagesITest: 10/10 green, including 5 new hasSearchMatch cases (match, no match, quoted phrases, cross-document isolation, no pages). Local caveat: this branch predates the Bump testcontainers-scala 0.41.4 -> 0.44.1 #755 testcontainers bump, so against Docker Engine 29+ the suite only runs with that bump applied (verified that way locally); CI runs it as-is.
  • Full frontend suite 222/222; CI=true npm run build typechecks clean.
  • Before removing WIP: a browser-level end-to-end pass of the final wiring (search click → Combined; stepping across paged/non-paged results).

Follow-up (out of scope)

Stepping next/previous between search results navigates without a ?view= (deliberately dropped by buildLink), so it always lands on the Combined default without consulting match reachability. Unifying it with the click path would mean passing the stepped-to result's fieldWithMostHighlights (already available on state.search.currentResults) through DocumentFooter.

Elasticsearch has no concept of the "Combined" view (it is assembled in
the frontend from the page index), so search picks the document field
with the most highlights (e.g. text, ocr.english) and forces that view
via ?view=. That suppressed the Combined default and landed users in
flat text/OCR even when Combined shows the same match in the view we
actually want people to use (#760).

For a paged document, prefer Combined whenever the search query is also
reachable there, and only fall back to the search-forced flat view when
it is not (lossy OCR, cross-field, or future translation matches that
don't exist in the page index). To learn whether the match is reachable
we probe /api/pages2/{uri}/search - the same request the PageViewer's
Controls already make once Combined is active.

The decision is resolved once per document load (guarded by a ref) so a
user manually switching to the Text/OCR tab afterwards is respected. The
switch goes through setResourceView, which the middleware syncs to the
URL via replace, so there is no back-button trap.

The view-choosing logic is extracted into a pure helper
(resolveInitialPagedView) with unit tests.
@hoyla
hoyla requested a review from a team as a code owner June 7, 2026 16:03
@hoyla hoyla added document viewer search fix Departmental tracking: fix labels Jun 7, 2026
@hoyla
hoyla marked this pull request as draft June 7, 2026 16:09
hoyla added 2 commits June 7, 2026 17:24
PageViewerOrFallback is not remounted when the :uri route param changes,
so its cached page-count response belonged to the previous document.
Stepping to the next/previous search result therefore evaluated the
view-choosing logic against a stale count: if the previous document was
paged but the next one is not, the viewer briefly treated the new
document as paged and imposed the Combined view on it, mounting the page
viewer and firing page fetches that 404 ("Not Found").

Clear the cached page count when the uri changes so the decision waits
for the new document's real count before choosing a view.

Also give useHighlightNavigation's fetch a catch so a pages 404 can never
surface as an uncaught runtime error.
The previous attempt (clearing the count on uri change) was insufficient:
setResponse(null) only takes effect on the next render, but the
view-choosing effect runs in the same effect-flush and still read the
previous document's count, so it continued to impose Combined on the next
(non-paged) search result and 404.

Tag the cached count with the uri it was fetched for and treat it as
"not yet known" until the current document's count arrives. The view
decision and the page-viewer render now both wait for the correct count
instead of acting on the previous document's.

Verified by driving the browser: paged result -> Combined; next result
(non-paged .odt) -> its own Text view, no Combined imposed, no runtime
error; previous result -> back to Combined.
@hoyla hoyla added the PM x AI label Jun 7, 2026
@hoyla hoyla self-assigned this Jun 7, 2026
@hoyla hoyla added the frontend label Jun 7, 2026
@hoyla hoyla changed the title Take search results to the Combined view when the match is there [WIP] Take search results to the Combined view when the match is there Jun 8, 2026
The frontend probe hit /api/pages2/:uri/search, which is not a cheap
existence check: per hit page it does a permission check, an ES
multi-search, an S3 PDF download and PDFBox geometry extraction (capped
at 501 pages) - and Controls re-fires the identical request once
Combined is active, so every clicked paged result paid it twice, with
the flat view flashing while the decision waited.

Instead, getPageCount now accepts the search query and returns
searchMatchesInPages alongside the count: one cheap ES count query
(Pages2.hasSearchMatch) using the same query builder as the page
highlighters, so quoting and stemming semantics match what Combined
will actually show. A failed or unparseable query recovers to "no
match" - opening the document must never break because of q.

With every decision input arriving in one response, the wait/keep/set
resolver states, the decidedForUri ref and the separate probe and
decision effects are no longer needed: the view is decided once, inside
the pageCount fetch callback, and resolveInitialPagedView shrinks to a
pure function returning the view to switch to (or undefined to leave it
alone). Nothing re-fires on view changes, so a manual tab switch can
never be overridden, and dispatching before setResponse means no frame
renders in the view being left.

The fetch effect also gains a cancellation guard: without it, a late
response from a superseded request (stepping quickly between results)
could clobber the current document's data with a mismatched uri tag -
blanking the viewer - or dispatch a view change after navigating away.

An older backend simply omits the new field, which resolves to keeping
the search-forced view, i.e. the pre-existing behaviour.
@hoyla hoyla added the backend label Jul 14, 2026
@hoyla hoyla changed the title [WIP] Take search results to the Combined view when the match is there Take search results to the Combined view when the match is there Jul 16, 2026
@hoyla
hoyla marked this pull request as ready for review July 16, 2026 08:05
@hoyla hoyla removed their assignment Jul 16, 2026

@philmcmahon philmcmahon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

combine

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In Giant, no search ever takes you to the Combined view

2 participants