Skip to content

feat(share): show matched content snippet in share search results#9962

Open
perfectra1n wants to merge 2 commits into
TriliumNext:mainfrom
perfectra1n:feat/share-search-snippets
Open

feat(share): show matched content snippet in share search results#9962
perfectra1n wants to merge 2 commits into
TriliumNext:mainfrom
perfectra1n:feat/share-search-snippets

Conversation

@perfectra1n

Copy link
Copy Markdown
Member

Summary

When you "share" notes from Trilium as a static site (or via the dynamic share viewer) and use the search box, the result list shows only the note title and its path. If the search term doesn't appear in the title, there's no way to tell why a result matched — which made the search feel broken even when it was finding the right notes.

Example: searching for Magnesium on a shared subtree returns five hospital/SRI reports, none of whose titles contain "Magnesium". The user has no idea what each note matched on:

image

This PR adds a content snippet under each result, with the matched terms bolded.

Changes

Server-side (dynamic share)

apps/server/src/share/routes.ts/share/api/notes now calls the existing searchService.extractContentSnippet + searchService.highlightSearchResults helpers (already used by the in-app autocomplete) for the top 20 results, and returns:

  • snippet — plain-text snippet
  • highlightedSnippet — HTML where matched tokens are wrapped in <b>...</b>

Snippet extraction is capped at 20 results (the popout only renders 5) so the existing payload size for large result sets stays the same. Results beyond the cap are still returned, just without a snippet.

Static (Fuse) share-export

packages/share-theme/src/scripts/modules/search.ts — the Fuse instance now opts in to includeMatches. The client builds a windowed snippet centred on the first content match, wraps every match-range inside the window in <b>...</b>, and falls back to a head-of-content preview when only the title matched.

Share-theme client

  • buildResultItem renders a new .search-result-snippet line under the path.
  • All user-supplied strings (title, path, plain snippet) are now HTML-escaped before injection. The server-rendered highlighted snippet is already sanitized by the search service (only <b> / <br> tags are inserted, and the service strips < / > / { / } from the source content first) and is inserted as-is.
  • New .search-result-snippet styling in packages/share-theme/src/styles/popouts/search.css: 12 px, two-line clamp, bolded match runs that recolor on hover so highlights stay legible against the active-row background.

Notes

  • No new dependencies; only reuses helpers already exported from searchService.
  • API change is additive (two new optional fields on the response). Older shared-export bundles that don't have the new client will simply ignore the fields.
  • Tested against both code paths:
    • Dynamic: GET /share/api/notes?ancestorNoteId=...&search=magnesium now includes snippet/highlightedSnippet.
    • Static: rebuilt a sample export and confirmed search-index.json is unchanged and the popout shows highlighted snippets from the Fuse matches payload.

The share search popout previously rendered only the title and path of
each hit, so users had no way to tell *what* in the note matched their
query (especially when the title alone did not contain the term).

Server-side dynamic share search:
- /share/api/notes now calls the existing extractContentSnippet +
  highlightSearchResults helpers (already used by the in-app
  autocomplete) for the top 20 results, and returns the plain-text
  snippet plus an HTML-highlighted variant where matched tokens are
  wrapped in <b>...</b>.

Static (Fuse) share-export search:
- The Fuse instance now opts in to includeMatches, and the client
  builds a windowed snippet centred on the first content match with
  matched ranges wrapped in <b>...</b>.

Share-theme client:
- buildResultItem renders a new .search-result-snippet line under the
  path. All user-supplied strings (title, path, snippet text) are now
  HTML-escaped before injection. The server-rendered highlighted
  snippet is already sanitized by the search service (only <b> / <br>
  tags) and is inserted as-is.
- New .search-result-snippet styling: 12px, two-line clamp, bolded
  match runs that recolor on hover so highlights stay legible against
  the active-row background.
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label May 27, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces search result content snippets with highlighted matching tokens for both the server-rendered share viewer and the static-export search index (using Fuse.js). It adds HTML escaping, snippet extraction, and CSS styling to display the matched snippets cleanly. A review comment identifies a bug in the static snippet generation where overlapping or adjacent match ranges can cause duplicated characters in the output, suggesting a range-merging algorithm to resolve the issue.

Comment on lines +74 to +82
const ranges = contentMatch.indices
.map(([s, e]) => [Math.max(s, from), Math.min(e + 1, to)] as [number, number])
.filter(([s, e]) => e > s)
.sort((a, b) => a[0] - b[0]);
for (const [s, e] of ranges) {
if (s > cursor) out += escapeHtml(content.slice(cursor, s));
out += `<b>${escapeHtml(content.slice(s, e))}</b>`;
cursor = e;
}

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.

high

The current implementation of snippet generation has a correctness bug when match ranges overlap or are adjacent. Because it processes each range independently without merging, any overlapping parts will result in duplicated characters in the rendered HTML snippet. Additionally, it maps and filters all match indices in the document, even those far outside the snippet window, which can be inefficient for large documents.

We can fix both issues by:

  1. Filtering the indices to only those intersecting the snippet window before mapping.
  2. Merging overlapping or adjacent ranges before rendering.
    const ranges = contentMatch.indices
        .filter(([s, e]) => s < to && e >= from)
        .map(([s, e]) => [Math.max(s, from), Math.min(e + 1, to)] as [number, number])
        .sort((a, b) => a[0] - b[0]);

    const mergedRanges: [number, number][] = [];
    for (const [s, e] of ranges) {
        if (mergedRanges.length === 0) {
            mergedRanges.push([s, e]);
        } else {
            const last = mergedRanges[mergedRanges.length - 1];
            if (s <= last[1]) {
                last[1] = Math.max(last[1], e);
            } else {
                mergedRanges.push([s, e]);
            }
        }
    }

    for (const [s, e] of mergedRanges) {
        if (s > cursor) out += escapeHtml(content.slice(cursor, s));
        out += `<b>${escapeHtml(content.slice(s, e))}</b>`;
        cursor = e;
    }

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

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant