feat(search): 'why this matched' highlights (ts_headline)
CI / backend (pull_request) Successful in 48s
CI / frontend (pull_request) Successful in 14s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 51s

Slice 3 of search-core. Search results now show WHY they matched: a ts_headline
body fragment with matched terms highlighted. The API returns U+E000/U+E001
sentinels around matches (NOT HTML); the client tokenises them into escaped
<mark> React elements, so untrusted email content can never inject markup (no
dangerouslySetInnerHTML). Subtle accent tint. Only when there's a free-text query
(browse unchanged); projection branched (no DB function in a ternary) for
unambiguous EF translation.

Verified: build + all 40 tests; ts_headline output shape in raw Postgres; and the
ranked+headline EF query proven to translate + execute against a live Postgres via
a throwaway test (removed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-01 22:14:15 +02:00
parent fcf290a83b
commit 4c43bfed0d
4 changed files with 69 additions and 11 deletions
@@ -77,15 +77,39 @@ public class SearchService : ISearchService
.ThenByDescending(e => e.SentAtUtc)
: q.OrderByDescending(e => e.SentAtUtc);
var items = await ranked
.Skip((r.Page - 1) * r.PageSize)
.Take(r.PageSize)
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe))
.ToListAsync(ct);
var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
// Two unconditional projections (no DB function inside a C# ternary → no doubt about
// EF translation). The browse path never touches ts_headline, so it's byte-for-byte
// unchanged AND safe under the InMemory test provider.
List<EmailSummaryDto> items;
if (hasFreeTextQuery)
{
// "Why this matched": ts_headline body fragment with matched terms wrapped in
// U+E000/U+E001 sentinels (safe, non-HTML — the client renders them as escaped
// <mark> spans; see EmailSummaryDto).
var headlineOpts =
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
items = await paged
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
.ToListAsync(ct);
}
else
{
items = await paged
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
null))
.ToListAsync(ct);
}
return new PagedResult<EmailSummaryDto>
{