Search: Elasticsearch-style highlighted match snippets (results + typeahead)
CI/CD / CI · dotnet build (push) Successful in 6m9s
CI/CD / Deploy · hamkadr (push) Has been cancelled

- SearchHighlight.Snippet: extracts a ±70-char window around the first
  matching term and marks it (with ellipses) — the ES "highlight" fragment.
- Result cards (shift/job/talent) now show that snippet from the matched
  description/tags when a query is present, so you SEE where the term hit
  (e.g. «…دارای مدرک <mark>mmt</mark>…») instead of just the role.
- Typeahead suggestions gain a highlighted "sub" line (talent→tags,
  shift→city·specialty, job→facility·city) so matches show in the dropdown too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 21:43:50 +03:30
parent bd8d754ee8
commit 8b0b21f24d
7 changed files with 59 additions and 7 deletions
@@ -34,6 +34,11 @@
{
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
}
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
@if (snip.Value.Length > 0)
{
<div class="search-snippet">@snip</div>
}
<div class="foot">
<span class="pay">@salary</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
@@ -259,8 +259,10 @@
.then(function (items) {
if (!items || !items.length) { hide(); return; }
var html = items.map(function (it) {
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
'</span><span class="ns-label">' + hi(it.label, q) + '</span></a>';
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
'</span>' + sub + '</span></a>';
}).join('');
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
box.innerHTML = html;
@@ -35,6 +35,11 @@
}
<div class="row">📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)</div>
<div class="row">🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)</div>
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
@if (snip.Value.Length > 0)
{
<div class="search-snippet">@snip</div>
}
<partial name="_HourBar" model="Model" />
<div class="foot">
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
@@ -40,6 +40,11 @@
}
</div>
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description ?? Model.Tags, q); }
@if (snip.Value.Length > 0)
{
<div class="search-snippet">@snip</div>
}
@if (tags.Count > 0)
{
<div class="tag-chips">
+6 -5
View File
@@ -373,17 +373,17 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)))
.OrderByDescending(s => s.CreatedAt).Take(5)
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id)).ToListAsync();
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync();
var jobs = await db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like)))
.OrderByDescending(j => j.CreatedAt).Take(5)
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id)).ToListAsync();
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync();
var talent = await db.TalentListings
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like)))
.OrderByDescending(t => t.CreatedAt).Take(5)
.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id)).ToListAsync();
.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync();
// round-robin merge so all three types appear, capped at 5
var merged = new List<SuggestItem>();
@@ -398,5 +398,6 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
app.Run();
/// <summary>One typeahead suggestion row (lowercase props → camelCase JSON for the client).</summary>
public record SuggestItem(string type, string label, string url);
/// <summary>One typeahead suggestion row (lowercase props → camelCase JSON for the client).
/// <c>sub</c> is the matched-context line (tags/city/specialty) shown highlighted under the label.</summary>
public record SuggestItem(string type, string label, string url, string? sub = null);
@@ -24,4 +24,33 @@ public static class SearchHighlight
var marked = Regex.Replace(encoded, pattern, m => $"<mark>{m.Value}</mark>", RegexOptions.IgnoreCase);
return new HtmlString(marked);
}
/// <summary>
/// Elasticsearch-style highlight fragment: finds the first matching term in <paramref name="text"/>,
/// returns a window of ±<paramref name="radius"/> chars around it with the terms marked and ellipses
/// at the cut points. Empty when nothing matches (so callers can hide the line).
/// </summary>
public static HtmlString Snippet(string? text, string? query, int radius = 70)
{
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) return HtmlString.Empty;
var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(t => t.Length >= 2).ToList();
if (terms.Count == 0) return HtmlString.Empty;
var flat = Regex.Replace(text, @"\s+", " ").Trim();
int idx = -1, matchLen = 0;
foreach (var t in terms)
{
var i = flat.IndexOf(t, StringComparison.OrdinalIgnoreCase);
if (i >= 0 && (idx < 0 || i < idx)) { idx = i; matchLen = t.Length; }
}
if (idx < 0) return HtmlString.Empty;
var start = Math.Max(0, idx - radius);
var end = Math.Min(flat.Length, idx + matchLen + radius);
var slice = flat.Substring(start, end - start).Trim();
var prefix = start > 0 ? "…" : "";
var suffix = end < flat.Length ? "…" : "";
return new HtmlString(prefix + Mark(slice, query).Value + suffix);
}
}
+6 -1
View File
@@ -294,7 +294,12 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
.nav-search-results a:hover { background: var(--primary-soft); }
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
.nav-search-results .ns-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.nav-search-results .ns-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-search-results .ns-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ES-style matched snippet shown under a search-result card */
.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px;
background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; }
.nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; }
/* Big search box on the /Search page head */
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }