Compare commits

...

1 Commits

Author SHA1 Message Date
cesnimda 4c43bfed0d 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>
2026-07-01 22:18:27 +02:00
4 changed files with 69 additions and 11 deletions
+22 -1
View File
@@ -16,6 +16,25 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`; return `${(b / 1048576).toFixed(1)} MB`;
}; };
// "Why this matched": the API wraps matched terms in U+E000/U+E001 sentinels (NOT HTML).
// We tokenise and render the highlighted parts as <mark> React elements — React escapes
// all text nodes, so untrusted email content can never inject markup (no dangerouslySetInnerHTML).
const HL_START = String.fromCharCode(0xE000);
const HL_STOP = String.fromCharCode(0xE001);
const HL_RE = new RegExp(HL_START + '([\s\S]*?)' + HL_STOP, 'g');
function renderHighlight(s) {
const out = [];
let last = 0, key = 0, m;
HL_RE.lastIndex = 0;
while ((m = HL_RE.exec(s)) !== null) {
if (m.index > last) out.push(s.slice(last, m.index));
out.push(<mark key={key++}>{m[1]}</mark>);
last = HL_RE.lastIndex;
}
if (last < s.length) out.push(s.slice(last));
return out;
}
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) { export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -83,7 +102,9 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
</td> </td>
<td className="el-subject"> <td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span> <span className="el-subj-text">{email.subject || '(no subject)'}</span>
{email.snippet && <span className="el-snippet"> {email.snippet}</span>} {email.matchHighlight
? <span className="el-snippet"> {renderHighlight(email.matchHighlight)}</span>
: email.snippet && <span className="el-snippet"> {email.snippet}</span>}
</td> </td>
<td className="el-meta"> <td className="el-meta">
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>} {email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
+8
View File
@@ -64,6 +64,14 @@
border-color: hsl(var(--border)); border-color: hsl(var(--border));
} }
/* Search "why this matched" highlight — subtle accent tint, not the default yellow. */
mark {
background: hsl(var(--primary) / 0.22);
color: inherit;
border-radius: 3px;
padding: 0 1px;
}
body { body {
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
/* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */ /* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */
+6 -1
View File
@@ -16,7 +16,12 @@ public record EmailSummaryDto(
long SizeEstimateBytes, long SizeEstimateBytes,
EmailCategory Category, EmailCategory Category,
bool HasListUnsubscribe, bool HasListUnsubscribe,
bool SupportsOneClick); bool SupportsOneClick,
// "Why this matched": a ts_headline fragment of the body with matched terms wrapped in
// U+E000/U+E001 sentinels (NOT HTML — the client renders them as escaped <mark> elements,
// so untrusted email content can never inject markup). Null unless the search had a
// free-text query. Optional/last so other DTO constructors are unaffected.
string? MatchHighlight = null);
/// <summary>Full single-email view, including body text, for the detail pane.</summary> /// <summary>Full single-email view, including body text, for the detail pane.</summary>
public record EmailDetailDto( public record EmailDetailDto(
@@ -77,15 +77,39 @@ public class SearchService : ISearchService
.ThenByDescending(e => e.SentAtUtc) .ThenByDescending(e => e.SentAtUtc)
: q.OrderByDescending(e => e.SentAtUtc); : q.OrderByDescending(e => e.SentAtUtc);
var items = await ranked var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
.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( .Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category, e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe)) e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
.ToListAsync(ct); .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> return new PagedResult<EmailSummaryDto>
{ {