| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | using Chronicis.Api.Data; |
| | | 3 | | using Chronicis.Api.Infrastructure; |
| | | 4 | | using Chronicis.Api.Services; |
| | | 5 | | using Chronicis.Shared.DTOs; |
| | | 6 | | using Chronicis.Shared.Extensions; |
| | | 7 | | using Microsoft.AspNetCore.Authorization; |
| | | 8 | | using Microsoft.AspNetCore.Mvc; |
| | | 9 | | using Microsoft.EntityFrameworkCore; |
| | | 10 | | |
| | | 11 | | namespace Chronicis.Api.Controllers; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// API endpoints for Global Search operations. |
| | | 15 | | /// </summary> |
| | | 16 | | [ApiController] |
| | | 17 | | [Route("search")] |
| | | 18 | | [Authorize] |
| | | 19 | | public class SearchController : ControllerBase |
| | | 20 | | { |
| | | 21 | | private readonly ChronicisDbContext _context; |
| | | 22 | | private readonly ICurrentUserService _currentUserService; |
| | | 23 | | private readonly ILogger<SearchController> _logger; |
| | | 24 | | private readonly IArticleHierarchyService _hierarchyService; |
| | | 25 | | |
| | | 26 | | // Regex to extract hashtags from content |
| | 0 | 27 | | private static readonly Regex HashtagPattern = new( |
| | 0 | 28 | | @"#([a-zA-Z][a-zA-Z0-9_]*)", |
| | 0 | 29 | | RegexOptions.Compiled); |
| | | 30 | | |
| | 0 | 31 | | public SearchController( |
| | 0 | 32 | | ChronicisDbContext context, |
| | 0 | 33 | | ICurrentUserService currentUserService, |
| | 0 | 34 | | ILogger<SearchController> logger, |
| | 0 | 35 | | IArticleHierarchyService hierarchyService) |
| | | 36 | | { |
| | 0 | 37 | | _context = context; |
| | 0 | 38 | | _currentUserService = currentUserService; |
| | 0 | 39 | | _hierarchyService = hierarchyService; |
| | 0 | 40 | | _logger = logger; |
| | 0 | 41 | | } |
| | | 42 | | |
| | | 43 | | /// <summary> |
| | | 44 | | /// GET /api/search?query={query} |
| | | 45 | | /// Searches across all article content the user has access to. |
| | | 46 | | /// </summary> |
| | | 47 | | [HttpGet] |
| | | 48 | | public async Task<ActionResult<GlobalSearchResultsDto>> Search([FromQuery] string query) |
| | | 49 | | { |
| | 0 | 50 | | var user = await _currentUserService.GetRequiredUserAsync(); |
| | | 51 | | |
| | 0 | 52 | | if (string.IsNullOrWhiteSpace(query) || query.Length < 2) |
| | | 53 | | { |
| | 0 | 54 | | return Ok(new GlobalSearchResultsDto |
| | 0 | 55 | | { |
| | 0 | 56 | | Query = query ?? "", |
| | 0 | 57 | | TitleMatches = new List<ArticleSearchResultDto>(), |
| | 0 | 58 | | BodyMatches = new List<ArticleSearchResultDto>(), |
| | 0 | 59 | | HashtagMatches = new List<ArticleSearchResultDto>(), |
| | 0 | 60 | | TotalResults = 0 |
| | 0 | 61 | | }); |
| | | 62 | | } |
| | | 63 | | |
| | 0 | 64 | | _logger.LogDebugSanitized("Searching for '{Query}' for user {UserId}", query, user.Id); |
| | | 65 | | |
| | | 66 | | // Get all world IDs the user has access to |
| | 0 | 67 | | var accessibleWorldIds = await _context.WorldMembers |
| | 0 | 68 | | .Where(wm => wm.UserId == user.Id) |
| | 0 | 69 | | .Select(wm => wm.WorldId) |
| | 0 | 70 | | .ToListAsync(); |
| | | 71 | | |
| | 0 | 72 | | var normalizedQuery = query.ToLowerInvariant(); |
| | | 73 | | |
| | | 74 | | // Title matches |
| | 0 | 75 | | var titleMatches = await _context.Articles |
| | 0 | 76 | | .Where(a => a.WorldId.HasValue && accessibleWorldIds.Contains(a.WorldId.Value)) |
| | 0 | 77 | | .Where(a => a.Title != null && a.Title.ToLower().Contains(normalizedQuery)) |
| | 0 | 78 | | .OrderBy(a => a.Title) |
| | 0 | 79 | | .Take(20) |
| | 0 | 80 | | .Select(a => new ArticleSearchResultDto |
| | 0 | 81 | | { |
| | 0 | 82 | | Id = a.Id, |
| | 0 | 83 | | Title = a.Title ?? "Untitled", |
| | 0 | 84 | | Slug = a.Slug, |
| | 0 | 85 | | MatchSnippet = a.Title ?? "", |
| | 0 | 86 | | MatchType = "title", |
| | 0 | 87 | | LastModified = a.ModifiedAt ?? a.CreatedAt, |
| | 0 | 88 | | AncestorPath = new List<BreadcrumbDto>() // Will be populated below |
| | 0 | 89 | | }) |
| | 0 | 90 | | .ToListAsync(); |
| | | 91 | | |
| | | 92 | | // Body content matches |
| | 0 | 93 | | var bodyMatches = await _context.Articles |
| | 0 | 94 | | .Where(a => a.WorldId.HasValue && accessibleWorldIds.Contains(a.WorldId.Value)) |
| | 0 | 95 | | .Where(a => a.Body != null && a.Body.ToLower().Contains(normalizedQuery)) |
| | 0 | 96 | | .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt) |
| | 0 | 97 | | .Take(20) |
| | 0 | 98 | | .Select(a => new ArticleSearchResultDto |
| | 0 | 99 | | { |
| | 0 | 100 | | Id = a.Id, |
| | 0 | 101 | | Title = a.Title ?? "Untitled", |
| | 0 | 102 | | Slug = a.Slug, |
| | 0 | 103 | | MatchSnippet = "", // Will extract snippet below |
| | 0 | 104 | | MatchType = "content", |
| | 0 | 105 | | LastModified = a.ModifiedAt ?? a.CreatedAt, |
| | 0 | 106 | | AncestorPath = new List<BreadcrumbDto>() |
| | 0 | 107 | | }) |
| | 0 | 108 | | .ToListAsync(); |
| | | 109 | | |
| | | 110 | | // Extract snippets for body matches |
| | 0 | 111 | | foreach (var match in bodyMatches) |
| | | 112 | | { |
| | 0 | 113 | | var article = await _context.Articles.FindAsync(match.Id); |
| | 0 | 114 | | if (article?.Body != null) |
| | | 115 | | { |
| | 0 | 116 | | match.MatchSnippet = ExtractSnippet(article.Body, query, 100); |
| | | 117 | | } |
| | 0 | 118 | | } |
| | | 119 | | |
| | | 120 | | // Hashtag matches (search for #query in body) |
| | 0 | 121 | | var hashtagQuery = $"#{query}"; |
| | 0 | 122 | | var hashtagMatches = await _context.Articles |
| | 0 | 123 | | .Where(a => a.WorldId.HasValue && accessibleWorldIds.Contains(a.WorldId.Value)) |
| | 0 | 124 | | .Where(a => a.Body != null && a.Body.Contains(hashtagQuery)) |
| | 0 | 125 | | .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt) |
| | 0 | 126 | | .Take(20) |
| | 0 | 127 | | .Select(a => new ArticleSearchResultDto |
| | 0 | 128 | | { |
| | 0 | 129 | | Id = a.Id, |
| | 0 | 130 | | Title = a.Title ?? "Untitled", |
| | 0 | 131 | | Slug = a.Slug, |
| | 0 | 132 | | MatchSnippet = "", // Will extract snippet below |
| | 0 | 133 | | MatchType = "hashtag", |
| | 0 | 134 | | LastModified = a.ModifiedAt ?? a.CreatedAt, |
| | 0 | 135 | | AncestorPath = new List<BreadcrumbDto>() |
| | 0 | 136 | | }) |
| | 0 | 137 | | .ToListAsync(); |
| | | 138 | | |
| | | 139 | | // Extract snippets for hashtag matches |
| | 0 | 140 | | foreach (var match in hashtagMatches) |
| | | 141 | | { |
| | 0 | 142 | | var article = await _context.Articles.FindAsync(match.Id); |
| | 0 | 143 | | if (article?.Body != null) |
| | | 144 | | { |
| | 0 | 145 | | match.MatchSnippet = ExtractSnippet(article.Body, hashtagQuery, 100); |
| | | 146 | | } |
| | 0 | 147 | | } |
| | | 148 | | |
| | | 149 | | // Build ancestor paths for all results |
| | 0 | 150 | | var allResults = titleMatches.Concat(bodyMatches).Concat(hashtagMatches).ToList(); |
| | 0 | 151 | | var ancestorOptions = new HierarchyWalkOptions |
| | 0 | 152 | | { |
| | 0 | 153 | | IncludeWorldBreadcrumb = false, |
| | 0 | 154 | | IncludeCurrentArticle = false |
| | 0 | 155 | | }; |
| | 0 | 156 | | foreach (var result in allResults) |
| | | 157 | | { |
| | 0 | 158 | | result.AncestorPath = await _hierarchyService.BuildBreadcrumbsAsync(result.Id, ancestorOptions); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | // Remove duplicates (same article appearing in multiple categories) |
| | 0 | 162 | | var seenIds = new HashSet<Guid>(); |
| | 0 | 163 | | var deduplicatedTitleMatches = titleMatches.Where(m => seenIds.Add(m.Id)).ToList(); |
| | 0 | 164 | | var deduplicatedBodyMatches = bodyMatches.Where(m => seenIds.Add(m.Id)).ToList(); |
| | 0 | 165 | | var deduplicatedHashtagMatches = hashtagMatches.Where(m => seenIds.Add(m.Id)).ToList(); |
| | | 166 | | |
| | 0 | 167 | | var response = new GlobalSearchResultsDto |
| | 0 | 168 | | { |
| | 0 | 169 | | Query = query, |
| | 0 | 170 | | TitleMatches = deduplicatedTitleMatches, |
| | 0 | 171 | | BodyMatches = deduplicatedBodyMatches, |
| | 0 | 172 | | HashtagMatches = deduplicatedHashtagMatches, |
| | 0 | 173 | | TotalResults = deduplicatedTitleMatches.Count + deduplicatedBodyMatches.Count + deduplicatedHashtagMatches.C |
| | 0 | 174 | | }; |
| | | 175 | | |
| | 0 | 176 | | return Ok(response); |
| | 0 | 177 | | } |
| | | 178 | | |
| | | 179 | | /// <summary> |
| | | 180 | | /// Extracts a snippet of text around the first occurrence of the search term. |
| | | 181 | | /// </summary> |
| | | 182 | | private static string ExtractSnippet(string text, string searchTerm, int contextLength) |
| | | 183 | | { |
| | 0 | 184 | | if (string.IsNullOrEmpty(text)) |
| | 0 | 185 | | return ""; |
| | | 186 | | |
| | 0 | 187 | | var index = text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase); |
| | 0 | 188 | | if (index < 0) |
| | 0 | 189 | | return text.Length > contextLength * 2 ? text[..(contextLength * 2)] + "..." : text; |
| | | 190 | | |
| | 0 | 191 | | var start = Math.Max(0, index - contextLength); |
| | 0 | 192 | | var end = Math.Min(text.Length, index + searchTerm.Length + contextLength); |
| | | 193 | | |
| | 0 | 194 | | var snippet = text[start..end]; |
| | | 195 | | |
| | | 196 | | // Add ellipsis if we're not at the boundaries |
| | 0 | 197 | | if (start > 0) |
| | 0 | 198 | | snippet = "..." + snippet; |
| | 0 | 199 | | if (end < text.Length) |
| | 0 | 200 | | snippet += "..."; |
| | | 201 | | |
| | | 202 | | // Clean up any HTML/markdown for display |
| | 0 | 203 | | snippet = CleanForDisplay(snippet); |
| | | 204 | | |
| | 0 | 205 | | return snippet; |
| | | 206 | | } |
| | | 207 | | |
| | | 208 | | /// <summary> |
| | | 209 | | /// Cleans text for display by removing HTML tags and normalizing whitespace. |
| | | 210 | | /// </summary> |
| | | 211 | | private static string CleanForDisplay(string text) |
| | | 212 | | { |
| | | 213 | | // Remove HTML tags |
| | 0 | 214 | | text = Regex.Replace(text, @"<[^>]+>", " "); |
| | | 215 | | |
| | | 216 | | // Remove wiki link syntax [[guid|text]] or [[guid]] |
| | 0 | 217 | | text = Regex.Replace(text, @"\[\[[a-fA-F0-9\-]{36}(?:\|([^\]]+))?\]\]", "$1"); |
| | | 218 | | |
| | | 219 | | // Normalize whitespace |
| | 0 | 220 | | text = Regex.Replace(text, @"\s+", " "); |
| | | 221 | | |
| | 0 | 222 | | return text.Trim(); |
| | | 223 | | } |
| | | 224 | | |
| | | 225 | | } |