< Summary

Information
Class: Chronicis.Api.Controllers.SearchController
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/SearchController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 128
Coverable lines: 128
Total lines: 225
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 30
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
Search()0%420200%
ExtractSnippet(...)0%110100%
CleanForDisplay(...)100%210%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/SearchController.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Chronicis.Api.Data;
 3using Chronicis.Api.Infrastructure;
 4using Chronicis.Api.Services;
 5using Chronicis.Shared.DTOs;
 6using Chronicis.Shared.Extensions;
 7using Microsoft.AspNetCore.Authorization;
 8using Microsoft.AspNetCore.Mvc;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace Chronicis.Api.Controllers;
 12
 13/// <summary>
 14/// API endpoints for Global Search operations.
 15/// </summary>
 16[ApiController]
 17[Route("search")]
 18[Authorize]
 19public 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
 027    private static readonly Regex HashtagPattern = new(
 028        @"#([a-zA-Z][a-zA-Z0-9_]*)",
 029        RegexOptions.Compiled);
 30
 031    public SearchController(
 032        ChronicisDbContext context,
 033        ICurrentUserService currentUserService,
 034        ILogger<SearchController> logger,
 035        IArticleHierarchyService hierarchyService)
 36    {
 037        _context = context;
 038        _currentUserService = currentUserService;
 039        _hierarchyService = hierarchyService;
 040        _logger = logger;
 041    }
 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    {
 050        var user = await _currentUserService.GetRequiredUserAsync();
 51
 052        if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
 53        {
 054            return Ok(new GlobalSearchResultsDto
 055            {
 056                Query = query ?? "",
 057                TitleMatches = new List<ArticleSearchResultDto>(),
 058                BodyMatches = new List<ArticleSearchResultDto>(),
 059                HashtagMatches = new List<ArticleSearchResultDto>(),
 060                TotalResults = 0
 061            });
 62        }
 63
 064        _logger.LogDebugSanitized("Searching for '{Query}' for user {UserId}", query, user.Id);
 65
 66        // Get all world IDs the user has access to
 067        var accessibleWorldIds = await _context.WorldMembers
 068            .Where(wm => wm.UserId == user.Id)
 069            .Select(wm => wm.WorldId)
 070            .ToListAsync();
 71
 072        var normalizedQuery = query.ToLowerInvariant();
 73
 74        // Title matches
 075        var titleMatches = await _context.Articles
 076            .Where(a => a.WorldId.HasValue && accessibleWorldIds.Contains(a.WorldId.Value))
 077            .Where(a => a.Title != null && a.Title.ToLower().Contains(normalizedQuery))
 078            .OrderBy(a => a.Title)
 079            .Take(20)
 080            .Select(a => new ArticleSearchResultDto
 081            {
 082                Id = a.Id,
 083                Title = a.Title ?? "Untitled",
 084                Slug = a.Slug,
 085                MatchSnippet = a.Title ?? "",
 086                MatchType = "title",
 087                LastModified = a.ModifiedAt ?? a.CreatedAt,
 088                AncestorPath = new List<BreadcrumbDto>() // Will be populated below
 089            })
 090            .ToListAsync();
 91
 92        // Body content matches
 093        var bodyMatches = await _context.Articles
 094            .Where(a => a.WorldId.HasValue && accessibleWorldIds.Contains(a.WorldId.Value))
 095            .Where(a => a.Body != null && a.Body.ToLower().Contains(normalizedQuery))
 096            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 097            .Take(20)
 098            .Select(a => new ArticleSearchResultDto
 099            {
 0100                Id = a.Id,
 0101                Title = a.Title ?? "Untitled",
 0102                Slug = a.Slug,
 0103                MatchSnippet = "", // Will extract snippet below
 0104                MatchType = "content",
 0105                LastModified = a.ModifiedAt ?? a.CreatedAt,
 0106                AncestorPath = new List<BreadcrumbDto>()
 0107            })
 0108            .ToListAsync();
 109
 110        // Extract snippets for body matches
 0111        foreach (var match in bodyMatches)
 112        {
 0113            var article = await _context.Articles.FindAsync(match.Id);
 0114            if (article?.Body != null)
 115            {
 0116                match.MatchSnippet = ExtractSnippet(article.Body, query, 100);
 117            }
 0118        }
 119
 120        // Hashtag matches (search for #query in body)
 0121        var hashtagQuery = $"#{query}";
 0122        var hashtagMatches = await _context.Articles
 0123            .Where(a => a.WorldId.HasValue && accessibleWorldIds.Contains(a.WorldId.Value))
 0124            .Where(a => a.Body != null && a.Body.Contains(hashtagQuery))
 0125            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 0126            .Take(20)
 0127            .Select(a => new ArticleSearchResultDto
 0128            {
 0129                Id = a.Id,
 0130                Title = a.Title ?? "Untitled",
 0131                Slug = a.Slug,
 0132                MatchSnippet = "", // Will extract snippet below
 0133                MatchType = "hashtag",
 0134                LastModified = a.ModifiedAt ?? a.CreatedAt,
 0135                AncestorPath = new List<BreadcrumbDto>()
 0136            })
 0137            .ToListAsync();
 138
 139        // Extract snippets for hashtag matches
 0140        foreach (var match in hashtagMatches)
 141        {
 0142            var article = await _context.Articles.FindAsync(match.Id);
 0143            if (article?.Body != null)
 144            {
 0145                match.MatchSnippet = ExtractSnippet(article.Body, hashtagQuery, 100);
 146            }
 0147        }
 148
 149        // Build ancestor paths for all results
 0150        var allResults = titleMatches.Concat(bodyMatches).Concat(hashtagMatches).ToList();
 0151        var ancestorOptions = new HierarchyWalkOptions
 0152        {
 0153            IncludeWorldBreadcrumb = false,
 0154            IncludeCurrentArticle = false
 0155        };
 0156        foreach (var result in allResults)
 157        {
 0158            result.AncestorPath = await _hierarchyService.BuildBreadcrumbsAsync(result.Id, ancestorOptions);
 159        }
 160
 161        // Remove duplicates (same article appearing in multiple categories)
 0162        var seenIds = new HashSet<Guid>();
 0163        var deduplicatedTitleMatches = titleMatches.Where(m => seenIds.Add(m.Id)).ToList();
 0164        var deduplicatedBodyMatches = bodyMatches.Where(m => seenIds.Add(m.Id)).ToList();
 0165        var deduplicatedHashtagMatches = hashtagMatches.Where(m => seenIds.Add(m.Id)).ToList();
 166
 0167        var response = new GlobalSearchResultsDto
 0168        {
 0169            Query = query,
 0170            TitleMatches = deduplicatedTitleMatches,
 0171            BodyMatches = deduplicatedBodyMatches,
 0172            HashtagMatches = deduplicatedHashtagMatches,
 0173            TotalResults = deduplicatedTitleMatches.Count + deduplicatedBodyMatches.Count + deduplicatedHashtagMatches.C
 0174        };
 175
 0176        return Ok(response);
 0177    }
 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    {
 0184        if (string.IsNullOrEmpty(text))
 0185            return "";
 186
 0187        var index = text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase);
 0188        if (index < 0)
 0189            return text.Length > contextLength * 2 ? text[..(contextLength * 2)] + "..." : text;
 190
 0191        var start = Math.Max(0, index - contextLength);
 0192        var end = Math.Min(text.Length, index + searchTerm.Length + contextLength);
 193
 0194        var snippet = text[start..end];
 195
 196        // Add ellipsis if we're not at the boundaries
 0197        if (start > 0)
 0198            snippet = "..." + snippet;
 0199        if (end < text.Length)
 0200            snippet += "...";
 201
 202        // Clean up any HTML/markdown for display
 0203        snippet = CleanForDisplay(snippet);
 204
 0205        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
 0214        text = Regex.Replace(text, @"<[^>]+>", " ");
 215
 216        // Remove wiki link syntax [[guid|text]] or [[guid]]
 0217        text = Regex.Replace(text, @"\[\[[a-fA-F0-9\-]{36}(?:\|([^\]]+))?\]\]", "$1");
 218
 219        // Normalize whitespace
 0220        text = Regex.Replace(text, @"\s+", " ");
 221
 0222        return text.Trim();
 223    }
 224
 225}