Skip to content

Search overhaul: matching accuracy, ranking, results UI, jump to match - #10633

Open
perfectra1n wants to merge 40 commits into
mainfrom
feat/search-overhaul
Open

Search overhaul: matching accuracy, ranking, results UI, jump to match#10633
perfectra1n wants to merge 40 commits into
mainfrom
feat/search-overhaul

Conversation

@perfectra1n

Copy link
Copy Markdown
Member

This overhauls search matching, ranking, the results UI, and match navigation. It started from the complaint list in #10616 and picks up the related open issues along the way.

Closes #10616, closes #9426, closes #9422, closes #5667, closes #5332, closes #6225, closes #3098. Related: #6991 (this lands a heuristic ranking upgrade rather than BM25; all weights live in one table, which is where configurable weights would plug in later if we want them).

Matching fixes

  • Exact word/phrase matching (the leading = prefix) is now punctuation aware. =sync finds a note containing (sync), sync, or "sync". Previously content was split on whitespace only, so any punctuation next to the word broke the match. Edge punctuation is stripped symmetrically from the query and the content, and underscores, + and inner apostrophes are kept, so c++, _private and d'Artagnan still work.
  • Fuzzy matching now scales edit distance with token length, like Elasticsearch's AUTO fuzziness: 1 or 2 characters means exact only, 3 to 5 characters allow 1 edit, 6 and more allow 2. This stops sync from matching Send and ceck from matching Tech (both were 2 edits on a 4 character word), while combinef still finds combined.
  • Fuzzy matching now also applies to note body content, via the existing progressive search phase 2. Before, fuzzy only ran against titles and attributes, which is why a typo in a word that lived in the body could not be found at all.
  • ~= and ~* now work on properties and labels (note.title ~= boks, #author ~= tolkein). This was a lexer bug: ~ was always treated as a relation prefix, so ~= never reached the parser even though the comparator supported it (Fuzzy search not working #9426). ~* also keeps fragment matching, so ~* progr finds "programming".
  • Reference links and link previews are now searchable. Internal reference links store stale or empty anchor text, so the target note's title is resolved at match time (injected resolver, the preprocessor stays browser safe). The data-url/data-title/data-description/data-site-name attributes of link embeds and link mentions are indexed too.

Ranking

Content matches previously contributed nothing to the score; only title and path did. That is why a note whose title merely contained async could outrank a note whose body had the exact word sync, and why scattered words beat an exact phrase. Content matches are now classified into tiers (exact phrase, all words in proximity with an in-order bonus, exact word, prefix, substring, fuzzy) and scored accordingly. The weights are chosen so an exact title match always dominates, and that invariant is enforced by a test.

Behavior change reviewers should look at: attribute = is strict again

#capital=Vienna used to match a label value of "Vienna Austria" because the = comparator drifted into word matching at some point. That contradicts the documented semantics (#9422). This branch restores strict full-value equality (case and diacritic insensitive) for attribute and property =/!=, and moves the word-match behavior to an internal operator used only by the leading = fulltext prefix, whose user-visible behavior is unchanged.

Who this affects: ETAPI searches, saved searches (and therefore bulk search-and-execute), the script APIs, share search, and getNotesWithLabel. All of them run with fuzzyAttributeSearch: false and silently inherited the word-match drift. An ETAPI regression test now pins the strict behavior. Quick search and autocomplete are unaffected (they run with relaxed attribute matching, which is now documented and covered by a test as well).

Results UI

  • The full search page now shows snippet cards: highlighted title, note path, a match-centered excerpt, and badges for matched attributes. Excerpts are generated server side by the same code quick search already used, and are fetched lazily per page through a new POST /api/search-note/:noteId/result-details endpoint (max 100 ids per request, ids are validated against the actual result set). Other view types for search notes are untouched; the cards are just the "list" view.
  • The result count is always visible, and the page size is configurable through a new synced option searchResultsPageSize (Settings for displaying search results  #6225).
  • Highlighting fixes: matches with diacritics are highlighted (ktory highlights ktorý), and regex searches (note.content %= '...') highlight their real matches instead of nothing ((Feature request) Improve search result highlighting #5332). Server snippet positions are computed on a guaranteed length-preserving normalization, which also let us drop the normalize-strings dependency.
  • The jump-to-note dialog (Ctrl+L) now shows content snippets. Inline note pickers are unchanged; the snippet line is opt-in per consumer.

Jump to match (#3098)

Opening a result from the search page, quick search, or the jump-to-note dialog scrolls to the first match and pre-fills the find bar, so Enter/F3 cycles through matches. The terms travel in a new viewScope.searchTerms that round-trips through link hashes, so ctrl+click into a new tab works too. Collapsed <details> blocks (CKEditor collapsibles) are expanded automatically when a find match or a bookmark target sits inside one; previously Ctrl+F would count those matches but could not show them.

Docs

Search.md and Quick search.md were rewritten around concrete examples, and every example in them (32 in total, including the pre-existing ones) is validated by a new docs_examples.spec.ts that runs the exact query strings against the real engine. Each test names the doc section it validates. This already paid off: it caught two pre-existing doc bugs (the negation example used note.ancestor.title, which is not a recognized specifier, and quick search's description of = was simply wrong).

Note for maintainers: these two files were edited directly as Markdown rather than through pnpm edit-docs:edit-docs, so a later edit-docs round trip may reformat them. The examples and the spec are kept in lockstep, so a reformat should not change any example text.

Testing

  • trilium-core search area: 33 spec files, 456 tests, including new specs for the tokenizer, AUTO distances, match tiers, the preprocessor, the result-details endpoint, and the docs examples.
  • ETAPI: new label-equality regression test. Routes, special_notes, share routes, benchmark and profiling suites all green.
  • Client: 182 spec files, 2307 tests, including the new card view, pagination precedence, the highlighting hook (diacritics, CJK, regex, invalid regex), the searchTerms hash round-trip (commas, percent signs, unicode), and the collapsible expansion helper.
  • Repo typecheck clean. No non-null assertions were introduced anywhere in the branch.

Manual checks before merging

These involve real-browser DOM behavior that the unit environment cannot cover:

  • Ctrl+F finds and reveals a match inside a collapsed block in a read-only text note
  • Same in an editable note, including F3 cycling into a collapsed block
  • A ?bookmark= link whose target sits inside a closed collapsible expands and scrolls to it
  • Closing the find bar after arriving from a search result removes all highlights
  • Quick search highlight colors look right on the TriliumNext themes (they now use the shared highlight variables)
  • Snippet cards on a narrow/mobile viewport

Follow-ups, will file separately

  • A shared execution timeout for user-supplied %= regex patterns (current bounds: pattern length cap plus match count caps).
  • Optional caching for the result-details endpoint (it re-runs the search per page, same cost as one quick-search keystroke; the handler has a comment reserving the spot).
  • Static share export search uses fuse.js client side and does not benefit from the ranking improvements.

Restore the documented label-equality semantics for the = and != operators:
they now compare the whole normalized value for strict equality instead of
word/phrase matching. #capital=Vienna matches label value "vienna" but no
longer "Vienna Austria".

The former word/phrase-match logic moves to a new internal "word=" operator
(not user-typable, absent from parse's OPERATORS) which the leading-"="
fulltext title comparison now uses so its user-visible behavior is unchanged.

Refs #9422
text/html notes previously lost their link previews and reference links to
search: stripTags deleted the data-* metadata of <section class="link-embed">
and <span class="link-mention"> elements, and reference-link anchors carry
stale/empty text (the title is resolved at render time).

preprocessContent now, before stripping tags, extracts data-url/title/
description/site-name from link previews (entity-decoded) and appends the
resolved title of each distinct internal-link target via an injected resolver,
keeping the preprocessor pure (no becca import). The one caller passes
becca.notes[id]?.title. Resolved titles become ordinary content words that
Task 2's tier classifier ranks automatically.

Refs #10616
Add HighlightedTokenInfo to commons and a regexTokens set +
getHighlightedTokenInfos() on SearchContext, tagging %= operator values
(parseLabel and note content fulltext) as regex tokens. searchFromNote's
result gains an additive highlightedTokenInfos array (highlightedTokens
stays untouched — public scripting API).

Make the snippet/highlight machinery regex-aware and consolidate its
position math on a new length-preserving core normalizer
(normalizePreservingLength) so match offsets found on normalized text map
1:1 onto the original when slicing/inserting markers — no index drift or
mid-marker splits across ligatures (ß, æ) and NFD-decomposed content.
Regex tokens compile to /token/gi (invalid patterns skipped) with a
per-field wrap cap; plain tokens behave as before.
Extract buildSearchResultDetails(results, searchContext) — the snippet
extraction + highlight + wire-shape map duplicated between the quickSearch
route and searchNotesForAutocomplete — and rebuild both call sites on it.
It runs the regex-aware snippet/highlight machinery with the context's
structured highlight tokens and now also includes noteId in the wire shape
(SearchResultDetails in commons).

Split searchFromNote into searchFromNoteWithContext(note), which returns
the raw SearchResults plus the producing SearchContext (null for
script-based searches), with searchFromNote reimplemented on top. Public
behavior unchanged.
Add POST /api/search-note/:noteId/result-details, which re-runs a saved
search and returns snippet + highlight details only for a requested page
of result noteIds (max 100), in requested order. Requested ids are
filtered against the actual result set so the endpoint can't be used as a
snippet oracle for arbitrary notes; script-based searches return
titles/icons with empty snippets and no token infos. Stateless (re-runs
per request); a future LRU keyed by (search noteId + searchString) is
noted but intentionally not built.

Wire types SearchResultDetailsRequest/Response live in commons alongside
HighlightedTokenInfo/SearchResultDetails.
Add a synced searchResultsPageSize option (default 20) for the full-search
results view's page size: interface entry in commons, default in
trilium-core options_init, and the ALLOWED_OPTIONS allowlist so the API
accepts changes. Inert until the client consumes it.
useImperativeSearchHighlighlighting now accepts structured
HighlightedTokenInfo tokens (plain/regex) alongside legacy string[].
Plain tokens use mark.js's term API with diacritics:true so an
unaccented query like "ktory" still highlights "ktorý" in result
previews (#10616); regex tokens (from %= searches) are compiled and
applied via markRegExp instead of being regex-escaped as literals,
so patterns like 'pat.*' actually highlight matches (#5332).

Plumbs highlightedTokenInfos from the search-note endpoint through
FNote and froca (falling back to mapping the legacy highlightedTokens
as plain when absent), and widens the highlightedTokens prop type
through ViewModeProps/NoteLink/NoteList/the legacy list-grid view/the
dashboard view so the wider type compiles end to end. All widening is
additive; existing string[] and null/undefined callers are unchanged.
Add an additive `defaultPageSize` parameter to `usePagination` so the search
results view can drive paging from the synced `searchResultsPageSize` option
while an explicit `#pageSize` label still wins. Clamp the current page back onto
the last valid page when the page size grows (or the result set shrinks) so the
slice can't yield an empty page. Existing list/grid callers are unaffected.
Render search-note results as Google-style snippet cards for the "list" view
type (issues #5667, #6225); other view types keep the legacy SearchNoteList
path so the ViewTypeSwitcher still works. Each card shows the live title/icon
from froca, a server-built highlighted snippet, an ancestor-path breadcrumb, and
outline badges for matched attributes, linking by bare noteId.

Snippet/highlight details are fetched lazily one page at a time via
`useSearchResultDetails`, with a sequence-ref stale-response guard and a
`searchRefreshed` refetch. The collection bar gains an always-visible result
count and a 10/20/50/100 page-size selector bound to `searchResultsPageSize`.

Also pass `highlightedTokenInfos ?? highlightedTokens` from `renderCollection`
so included/rendered search collections get regex-aware highlighting.
Quick-search dropdown highlight <b> tags now point at
--note-list-view-search-result-highlight-background/-color, the same
vars the snippet-card search results view uses, with fallbacks that
preserve the previous look on themes that don't define them.

Also adds highlightedTokens to the quickSearch route response (it
was the one snippet-response consumer Task 4's shared-builder refactor
missed) so the dropdown can stash the tokens for jump-to-match.
note_autocomplete's suggestion template can now render the server's
highlightedContentSnippet, gated behind a new showContentSnippets
option (default false) so link dialogs, relation editors and
@-mentions stay compact. Only the jump-to-note dialog opts in.

The jump-to-note dialog also carries the typed search string into
navigation as viewScope.searchTerms when a note is opened from a
text search (not the command palette, not an untyped recent-notes
pick), via a locally-widened ViewScope type — same forward-compatible
pattern SearchResultCard.tsx uses ahead of Task 8 promoting
searchTerms onto ViewScope proper.
actualText.current (the gate for viewScope.searchTerms in jump_to_note.tsx)
only updated on the native "input" DOM event, but several paths change the
autocomplete's value programmatically without firing one: the mid-session
"show recent notes" button, the "clear text" button (both in
note_autocomplete.ts), and the >120s-later dialog reopen resetting to
recent-notes (jump_to_note.tsx's openDialog). Each left the ref holding a
previous session's typed query, so picking an untyped/recent-notes
suggestion could still attach searchTerms from an earlier search.

- note_autocomplete.ts: clearText/setText/showRecentNotes/showAllCommands
  now re-trigger "input" after setting the value programmatically, so any
  consumer tracking the live query (not just this dialog) stays accurate.
- jump_to_note.tsx: openDialog syncs actualText.current with the
  freshly-computed initialText immediately, since the "recent-notes"
  branch bypasses NoteAutocomplete's text-prop path entirely.
- Extracted the gate itself into a pure, exported deriveSearchViewScope()
  and added a focused spec (jump_to_note.spec.ts) covering typed/blank/
  command-mode/cleared-after-typed inputs.
Promote `searchTerms` from a temporary local intersection type onto the real
ViewScope interface. calculateHash serializes non-empty search terms as a
`searchTerms` hash param (double-encoded so commas inside a token can't be
confused with the token separator); parseNavigationStateFromUrl decodes and
splits it back into an array, dropping any malformed percent-encoded token
instead of throwing.

SearchResultCard and jump_to_note now use the real ViewScope field and drop
their task-8-pending local aliases (SearchResultViewScope / JumpToNoteViewScope).
…bookmarks

Add expandCollapsedAncestors(el), which opens every closed <details> ancestor
of an element (CKEditor collapsibles and raw imported <details> alike).

Wire it in before every scroll-to-match/bookmark point that can land inside a
collapsed block, since scrollIntoView is a no-op for content with no layout
box:
- find_in_html.ts jumpTo() (read-only text/render/doc find, incl. find-next
  cycling) — fixes Ctrl+F being unable to reveal matches inside collapsed
  blocks (#10616).
- find_in_text.ts (editable CKEditor) after "find" and after
  "findNext"/"findPrevious", re-scrolling explicitly afterwards since
  CKEditor's own debounced scroll can't reach a still-hidden range.
- ReadOnlyText.tsx / EditableText.tsx bookmark-anchor scroll (?bookmark=...).
Extend the findInText command with an optional searchTerms payload. When
present, FindWidget seeds the find bar with the first token, jumps to the
first match, and (read-only HTML notes only) highlights the remaining tokens
with a muted secondary style so every matched term is visible while Enter/F3
keeps cycling only the seed matches. The bar opens whenever there is at least
one match so non-Electron users (no Ctrl+F) can cycle and dismiss it; with zero
matches nothing is shown and no marks are left behind.

Route the command by explicit ntxId when targeted (the seeded flow carries it)
so only the matching split's widget responds, falling back to the active
context for manual Ctrl+F. performFind now returns the match counts.

Groundwork for #3098 / #10616.
Add consumeSearchTerms: a one-shot consumer that reads viewScope.searchTerms
(carried in on note open, mirroring viewScope.bookmark), clears them, and fires
a seeded findInText deferred to the next animation frame so it runs after the
noteSwitched dispatch and can't be race-closed by FindWidget's own
noteSwitched -> closeSearch.

Wire it into the text and code type widgets at their content-ready point (the
[blob] effect for read-only, onContentChange for editable). A shared
useSearchTermsConsumer hook covers the same-note re-click case, where setNote
proceeds on viewScope inequality and fires noteSwitched without reloading the
blob; it is guarded to the note the widget already shows so a genuine switch is
left to the newly mounted widget's content-ready path.

Closes part of #3098 / #10616.
Build quick-search result links with calculateHash, attaching the last
search's highlighted tokens as viewScope.searchTerms (both the snippet items
and the froca-fallback createLink path). Opening a result now jumps to the
first match and pre-fills the find bar. Tokens are omitted when empty, so links
degrade to a plain #notePath.
Task 11 full integration pass over the search-overhaul branch:
- Fix a duplicate @triliumnext/commons import (no-duplicate-imports)
  in useSearchResultDetails.spec.tsx.
- Fix an unsorted import block in find_in_text.ts.
- Reword three stale "Task N" comments (SearchResultsList.spec.tsx,
  quick_search.ts, build_comparator.ts) to be self-contained now that
  the referenced tasks have landed.

No behavioral changes; typecheck, the full client suite, and the
touched search spec all still pass.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🖥️ App preview is ready!

🔗 Preview URL: https://pr-10633.trilium-app.pages.dev
📖 Production URL: https://app.triliumnotes.org

✅ All checks passed

This preview will be updated automatically with new commits.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 20.05kB (0.02%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
standalone-esm 53.29MB 10.47kB (0.02%) ⬆️
client-esm 49.56MB 9.57kB (0.02%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: standalone-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/abstract_provider-BOSCRPya.js (New) 2.08MB 2.08MB 100.0% 🚀
src/app_context.js 369 bytes 201.6kB 0.18%
assets/src-D4s-HaIf.js (New) 195.28kB 195.28kB 100.0% 🚀
src/Code.js 77 bytes 182.21kB 0.04%
assets/crypto_provider-D86biznd.js (New) 97.15kB 97.15kB 100.0% 🚀
src/layout_commons.js 5.29kB 93.01kB 6.03% ⚠️
src/desktop_layout.js 1 bytes 80.04kB 0.0%
assets/in_app_help_provider-DEWyjKyp.js (New) 78.74kB 78.74kB 100.0% 🚀
src/i18n.js -8 bytes 61.05kB -0.01%
assets/zip-DNj9CDtS.js (New) 56.09kB 56.09kB 100.0% 🚀
src/NoteActions.js -6 bytes 47.7kB -0.01%
src/PopupEditor.js -4 bytes 34.04kB -0.01%
src/layout_commons.css 2.62kB 26.62kB 10.9% ⚠️
src/hooks.js 656 bytes 26.04kB 2.58%
src/EditableText.js -74 bytes 23.5kB -0.31%
src/calendar.js -5 bytes 14.37kB -0.03%
src/note_autocomplete.js 213 bytes 12.11kB 1.79%
assets/browser_routes-DM0ZVnm2.js (New) 8.21kB 8.21kB 100.0% 🚀
src/ListOrGridView.js 116 bytes 7.51kB 1.57%
src/i18n2.js 1 bytes 6.52kB 0.02%
assets/becca_loader-DeGpRFCe.js (New) 5.71kB 5.71kB 100.0% 🚀
assets/local-server-worker-B5o1P9q1.js (New) 4.65kB 4.65kB 100.0% 🚀
src/OptionsDialog.js 5 bytes 3.15kB 0.16%
assets/html-WLqwPL4p.js (New) 2.93kB 2.93kB 100.0% 🚀
src/utils3.js 232 bytes 2.55kB 10.01% ⚠️
assets/ru-DrhVZbCf.js (New) 2.39kB 2.39kB 100.0% 🚀
assets/backup_provider-DD8s4G-d.js (New) 2.32kB 2.32kB 100.0% 🚀
assets/uk-CHLSbVhb.js (New) 2.31kB 2.31kB 100.0% 🚀
src/jump_to_note.js 173 bytes 2.18kB 8.62% ⚠️
assets/log_provider-D22DlPU-.js (New) 1.96kB 1.96kB 100.0% 🚀
assets/0216__move_content_into_blobs-69c_7ZBK.js (New) 1.9kB 1.9kB 100.0% 🚀
src/ReadOnlyText.js -94 bytes 1.87kB -4.78%
assets/zip_export_provider_factory-CIRv64Cq.js (New) 1.83kB 1.83kB 100.0% 🚀
src/Empty.js -5 bytes 1.82kB -0.27%
assets/ar-DjZuOZvE.js (New) 1.81kB 1.81kB 100.0% 🚀
assets/cs-DKGhJEJf.js (New) 1.78kB 1.78kB 100.0% 🚀
assets/pl-PkQpb0YL.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/hi-CRV6KZLW.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/zh-cn-Bcmn-Uu-.js (New) 1.54kB 1.54kB 100.0% 🚀
assets/de-DD-LFcxR.js (New) 1.52kB 1.52kB 100.0% 🚀
assets/zh-tw-Bb7onrVg.js (New) 1.51kB 1.51kB 100.0% 🚀
assets/ja-eOFacOG9.js (New) 1.35kB 1.35kB 100.0% 🚀
assets/pt-DM51xGE5.js (New) 1.3kB 1.3kB 100.0% 🚀
assets/ga-LQn2ah8l.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/en-gb-AuL4dnUw.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/pt-br-Cu_oZvsZ.js (New) 1.28kB 1.28kB 100.0% 🚀
assets/fr-BQmtCY-J.js (New) 1.27kB 1.27kB 100.0% 🚀
assets/es-C3slG5gB.js (New) 1.25kB 1.25kB 100.0% 🚀
assets/it-gNl1gY6P.js (New) 1.23kB 1.23kB 100.0% 🚀
assets/ro-BYhLwEPh.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/id-C6qSmrIx.js (New) 1.22kB 1.22kB 100.0% 🚀
src/RightPanelWidget.js 1 bytes 1.17kB 0.09%
assets/0233__migrate_geo_map_to_collection-EC119xME.js (New) 777 bytes 777 bytes 100.0% 🚀
src/highlights_list_options.js 1 bytes 681 bytes 0.15%
assets/0220__migrate_images_to_attachments-CqUiHGa2.js (New) 672 bytes 672 bytes 100.0% 🚀
assets/0239__disable_totp_when_mfa_was_turned_off-BUh-F479.js (New) 623 bytes 623 bytes 100.0% 🚀
assets/0234__migrate_ai_chat_to_code-pk09onHu.js (New) 443 bytes 443 bytes 100.0% 🚀
assets/markdown-DEblGVRP.js (New) 323 bytes 323 bytes 100.0% 🚀
assets/abstract_provider-CLBbFSrY.js (Deleted) -2.08MB 0 bytes -100.0% 🗑️
assets/src-B_n9IsEp.js (Deleted) -194.86kB 0 bytes -100.0% 🗑️
assets/crypto_provider-B72yp_Ke.js (Deleted) -97.15kB 0 bytes -100.0% 🗑️
assets/in_app_help_provider-Cmr_ThRB.js (Deleted) -78.74kB 0 bytes -100.0% 🗑️
assets/zip-CV1Tz_5p.js (Deleted) -56.09kB 0 bytes -100.0% 🗑️
assets/browser_routes-71UjaXqm.js (Deleted) -8.21kB 0 bytes -100.0% 🗑️
assets/becca_loader-1t8-bOx6.js (Deleted) -5.71kB 0 bytes -100.0% 🗑️
assets/local-server-worker-CXTQF70n.js (Deleted) -4.65kB 0 bytes -100.0% 🗑️
assets/html-D3_0QDLj.js (Deleted) -2.93kB 0 bytes -100.0% 🗑️
assets/ru-DQiRu-op.js (Deleted) -2.39kB 0 bytes -100.0% 🗑️
assets/backup_provider-C4KrWAU_.js (Deleted) -2.32kB 0 bytes -100.0% 🗑️
assets/uk-n30x1Mok.js (Deleted) -2.31kB 0 bytes -100.0% 🗑️
assets/log_provider-DgtL7TRY.js (Deleted) -1.96kB 0 bytes -100.0% 🗑️
assets/0216__move_content_into_blobs-Cc7OsslN.js (Deleted) -1.9kB 0 bytes -100.0% 🗑️
assets/zip_export_provider_factory-Ddu1gyQ2.js (Deleted) -1.83kB 0 bytes -100.0% 🗑️
assets/ar-Cv8h-4-c.js (Deleted) -1.81kB 0 bytes -100.0% 🗑️
assets/cs-C_O68rNT.js (Deleted) -1.78kB 0 bytes -100.0% 🗑️
assets/pl-C0n3oCI8.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/hi-CXluouqs.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/zh-cn-tRqQpTtu.js (Deleted) -1.54kB 0 bytes -100.0% 🗑️
assets/de-XIO86dfE.js (Deleted) -1.52kB 0 bytes -100.0% 🗑️
assets/zh-tw-B6BC2sHh.js (Deleted) -1.51kB 0 bytes -100.0% 🗑️
assets/ja-CUIEBgl2.js (Deleted) -1.35kB 0 bytes -100.0% 🗑️
assets/pt-C1mjqx87.js (Deleted) -1.3kB 0 bytes -100.0% 🗑️
assets/ga-4TzC2oPi.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/en-gb-BFXArJDT.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/pt-br-C1Fnjkgl.js (Deleted) -1.28kB 0 bytes -100.0% 🗑️
assets/fr-Bu-6khxJ.js (Deleted) -1.27kB 0 bytes -100.0% 🗑️
assets/es-BucMvrIb.js (Deleted) -1.25kB 0 bytes -100.0% 🗑️
assets/it-pTjpCT6D.js (Deleted) -1.23kB 0 bytes -100.0% 🗑️
assets/ro-BT-NrwOd.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/id-9xznik4a.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/0233__migrate_geo_map_to_collection-BeJSuOTe.js (Deleted) -777 bytes 0 bytes -100.0% 🗑️
assets/0220__migrate_images_to_attachments-BVbPmILx.js (Deleted) -672 bytes 0 bytes -100.0% 🗑️
assets/0239__disable_totp_when_mfa_was_turned_off-CNMq3Uhh.js (Deleted) -623 bytes 0 bytes -100.0% 🗑️
assets/0234__migrate_ai_chat_to_code-Bf1Y4Q1j.js (Deleted) -443 bytes 0 bytes -100.0% 🗑️
assets/markdown-Bc_SpRAn.js (Deleted) -323 bytes 0 bytes -100.0% 🗑️
view changes for bundle: client-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
src/dist-*.js 26 bytes 82 bytes 46.43% ⚠️
src/dist-*.js 6 bytes 63 bytes 10.53% ⚠️
src/dist-*.js -7 bytes 56 bytes -11.11%
src/dist-*.js -25 bytes 57 bytes -30.49%
src/src-*.js -70 bytes 139 bytes -33.49%
src/src-*.js 70 bytes 209 bytes 50.36% ⚠️
src/content_renderer-*.js 369 bytes 201.93kB 0.18%
src/Code-*.js 77 bytes 182.64kB 0.04%
src/ContentWidget-*.js -2 bytes 102.1kB -0.0%
src/layout_commons-*.js 5.3kB 93.72kB 6.0% ⚠️
src/desktop_layout-*.js 2 bytes 80.37kB 0.0%
src/i18n-*.js -8 bytes 61.06kB -0.01%
src/i18n-*.js 1 bytes 6.67kB 0.01%
src/NoteActions-*.js -11 bytes 48.02kB -0.02%
src/PopupEditor-*.js 1 bytes 34.29kB 0.0%
src/layout_commons-*.css 2.62kB 26.62kB 10.9% ⚠️
src/hooks-*.js 651 bytes 26.18kB 2.55%
src/EditableText-*.js -74 bytes 24.01kB -0.31%
src/note_autocomplete-*.js 213 bytes 12.14kB 1.79%
src/ListOrGridView-*.js 116 bytes 7.63kB 1.54%
src/utils-*.js 241 bytes 2.59kB 10.28% ⚠️
src/jump_to_note-*.js 173 bytes 2.27kB 8.26% ⚠️
src/ReadOnlyText-*.js -94 bytes 1.96kB -4.58%
src/RightPanelWidget-*.js 1 bytes 1.23kB 0.08%
src/SetupPage-*.js -5 bytes 951 bytes -0.52%
src/highlights_list_options-*.js 1 bytes 734 bytes 0.14%

Files in src/content_renderer-*.js:

  • ./src/components/app_context.ts → Total Size: 3.67kB

  • ./src/services/content_renderer.ts → Total Size: 16.2kB

  • ./src/services/froca.ts → Total Size: 8.36kB

  • ./src/entities/fnote.ts → Total Size: 18.84kB

  • ./src/services/link.ts → Total Size: 12.24kB

Files in src/layout_commons-*.js:

  • ./src/widgets/collections/search/useSearchResultDetails.ts → Total Size: 1.23kB

  • ./src/widgets/collections/search/SearchResultsList.css → Total Size: 0 bytes

  • ./src/widgets/collections/search/SearchResultCard.tsx → Total Size: 2.86kB

  • ./src/widgets/collections/search/SearchResultsList.tsx → Total Size: 2.47kB

Files in src/hooks-*.js:

  • ./src/services/collapsible.ts → Total Size: 255 bytes

  • ./src/services/search_jump.ts → Total Size: 435 bytes

Files in src/note_autocomplete-*.js:

  • ./src/services/note_autocomplete.ts → Total Size: 11.76kB

Files in src/ListOrGridView-*.js:

  • ./src/widgets/collections/legacy/ListOrGridView.tsx → Total Size: 9.19kB

  • ./src/widgets/collections/Pagination.tsx → Total Size: 4.42kB

Files in src/utils-*.js:

  • ./src/services/bookmark_jump.ts → Total Size: 434 bytes

@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation preview is ready!

🔗 Preview URL: https://pr-10633.trilium-docs.pages.dev
📖 Production URL: https://docs.triliumnotes.org

✅ All checks passed

This preview will be updated automatically with new commits.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This is a large search overhaul that fixes matching accuracy (punctuation-aware exact matching, AUTO-scaled fuzzy distances, ~=/~* lexer bug), restores strict attribute equality semantics for =/!=, adds content-match scoring tiers, and ships a new snippet-card results UI with lazy per-page detail fetching.

  • Server-side: New POST /api/search-note/:noteId/result-details endpoint returns per-note snippets lazily; searchFromNoteWithContext refactors the shared note-search flow so both the main route and the new endpoint re-use the same search/context pair; word= is introduced as a private comparator to preserve the leading-= fulltext behavior without leaking the old drift into attribute equality.
  • Client-side: SearchResultsList + SearchResultCard deliver a Google-style card view with highlighted snippets, breadcrumb paths, and attribute badges; usePagination gains a defaultPageSize override plus staleness guards for out-of-order froca loads.
  • Testing: 33 core spec files and 182 client spec files cover new behaviour; a docs_examples.spec.ts validates 32 documentation examples against the live engine.

Confidence Score: 5/5

Safe to merge. The matching, ranking, and results-UI changes are thoroughly tested (456 core + 2307 client tests, 32 doc examples validated). The strict-equality restoration for attribute = is a deliberate, documented behavior change with a regression test pinning it.

The core search logic is well-covered by unit tests including new specs for every major changed path. All findings are minor ranking-accuracy and code-organization observations that do not affect correctness.

match_quality.ts for the betterQuality inOrder tie-breaking; note_content_fulltext_preprocessor.ts for the module-level global regex lastIndex pattern.

Important Files Changed

Filename Overview
packages/trilium-core/src/services/search/match_quality.ts New module classifying content match tiers (fuzzy to exact_phrase); betterQuality tie-breaking drops inOrder when tier and token count are equal.
packages/trilium-core/src/services/search/services/search.ts Refactored to expose searchFromNoteWithContext; buildSearchResultDetails shared between quick-search and new result-details endpoint; highlighting rewritten regex-aware with MAX_HIGHLIGHT_WRAPS cap.
packages/trilium-core/src/routes/api/search.ts New getSearchResultDetails endpoint validates noteIds, re-runs search, and only returns details for notes in the actual result set.
packages/trilium-core/src/services/search/services/build_comparator.ts = operator restored to strict full-value equality; new internal word= comparator preserves old word-match semantics for the leading-= fulltext path only.
packages/trilium-core/src/services/search/expressions/note_content_fulltext_preprocessor.ts extractLinkSearchText adds searchable text from link previews and internal-link titles; INTERNAL_LINK_RE lastIndex correctly reset but the global-regex pattern is fragile.
apps/client/src/widgets/collections/search/SearchResultsList.tsx New snippet-card list view; computes pageNoteIds via useMemo separately from usePagination, which still fires a froca.getNotes() that SearchResultsList never consumes.
apps/client/src/widgets/collections/search/useSearchResultDetails.ts Sequence-ref guard correctly prevents stale responses from overwriting newer page fetches.
apps/client/src/widgets/collections/Pagination.tsx defaultPageSize fallback added; out-of-range clamp effect and cancellation flag for stale loads correctly address the previously flagged race condition.

Reviews (4): Last reviewed commit: "Merge origin/main into feat/search-overh..." | Re-trigger Greptile

Comment thread apps/client/src/services/search_jump.ts
Comment thread apps/client/src/widgets/collections/Pagination.tsx
…nst navigation races

Review feedback on #10633: the rAF-deferred findInText could target a newer note if the tab navigated within the frame (now aborted via viewScope identity), and overlapping froca.getNotes loads in usePagination could resolve out of order and overwrite the current page (now cancelled on supersession; out-of-range slices are skipped while the page clamp settles).
ReadOnlyText's bookmark effect ([blob] deps) also fired on mount, while the
blob was still loading and the content container was empty, and unconditionally
cleared viewScope.bookmark — so the post-load run had nothing left to reveal
and the collapsible expansion never happened on a cold open.

Extract the reveal-and-consume logic into services/bookmark_jump.ts
(consumeBookmark), shared by ReadOnlyText and EditableText: a missing
container leaves the bookmark unconsumed for the next pass, a dangling anchor
is consumed without scrolling, and the target is found by exact id comparison
instead of interpolating user text into a CSS attribute selector (ids with
quotes/brackets used to break the selector). ReadOnlyText's effect is now
gated on the blob being present.
Reconciles the overlap with the collapsible-blocks work that landed in main
(#10635, #10628) while this branch was in review:

- services/collapsible.ts (main's expandAncestorDetails) is now the single
  collapsible-expansion helper; this branch's duplicate services/collapsibles.ts
  is removed and bookmark_jump/find_in_html use main's module. The param is
  widened to Element for the bookmark path, and the mixed open/closed chain and
  self-is-details cases from the removed spec are ported into collapsible.spec.ts.
- find_in_text's revealSelectedFindResult is dropped: the collapsible plugin now
  owns find-reveal in the editable editor (transient editing-view open), and a
  direct DOM toggle would be adopted into the persisted model — a plain Ctrl+F
  must not rewrite the note's saved open/closed layout.
- useImperativeSearchHighlighlighting keeps this branch's token-info rewrite
  (diacritics, CJK, regex tokens with caps) and gains main's post-mark pass that
  expands <details> around highlighted results; specs merged accordingly.
- ReadOnlyText.spec.tsx combines main's #10575 suite with this branch's
  ?bookmark= consume-race regression test, ported onto main's harness.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-conflicts size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

2 participants