feat(search): 'why this matched' highlights (ts_headline)
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:
@@ -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>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user