feat(search): why this matched highlights (#15)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 47s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 47s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
This commit was merged in pull request #15.
This commit is contained in:
@@ -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>}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
.Select(e => new EmailSummaryDto(
|
// EF translation). The browse path never touches ts_headline, so it's byte-for-byte
|
||||||
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
|
// unchanged AND safe under the InMemory test provider.
|
||||||
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
|
List<EmailSummaryDto> items;
|
||||||
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
|
if (hasFreeTextQuery)
|
||||||
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe))
|
{
|
||||||
.ToListAsync(ct);
|
// "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>
|
return new PagedResult<EmailSummaryDto>
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user