< Summary

Information
Class: Chronicis.Api.Services.SearchReadService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/SearchReadService.cs
Line coverage
100%
Covered lines: 23
Uncovered lines: 0
Coverable lines: 23
Total lines: 190
Line coverage: 100%
Branch coverage
100%
Covered branches: 10
Total branches: 10
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExtractSnippet(...)100%1010100%
CleanForDisplay(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/SearchReadService.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Chronicis.Api.Data;
 3using Chronicis.Shared.DTOs;
 4using Microsoft.EntityFrameworkCore;
 5
 6namespace Chronicis.Api.Services;
 7
 8public sealed partial class SearchReadService : ISearchReadService
 9{
 10    private readonly ChronicisDbContext _context;
 11    private readonly IArticleHierarchyService _hierarchyService;
 12    private readonly IReadAccessPolicyService _readAccessPolicy;
 13
 14    public SearchReadService(
 15        ChronicisDbContext context,
 16        IArticleHierarchyService hierarchyService,
 17        IReadAccessPolicyService readAccessPolicy)
 18    {
 319        _context = context;
 320        _hierarchyService = hierarchyService;
 321        _readAccessPolicy = readAccessPolicy;
 322    }
 23
 24    public async Task<GlobalSearchResultsDto> SearchAsync(string query, Guid userId)
 25    {
 26        if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
 27        {
 28            return new GlobalSearchResultsDto
 29            {
 30                Query = query ?? string.Empty,
 31                TitleMatches = new List<ArticleSearchResultDto>(),
 32                BodyMatches = new List<ArticleSearchResultDto>(),
 33                HashtagMatches = new List<ArticleSearchResultDto>(),
 34                TotalResults = 0
 35            };
 36        }
 37
 38        var normalizedQuery = query.ToLowerInvariant();
 39        var readableWorldArticles = _readAccessPolicy
 40            .ApplyAuthenticatedWorldArticleFilter(_context.Articles.AsNoTracking(), userId);
 41
 42        var titleMatches = await readableWorldArticles
 43            .Where(a => a.Title != null && a.Title.ToLower().Contains(normalizedQuery))
 44            .OrderBy(a => a.Title)
 45            .Take(20)
 46            .Select(a => new ArticleSearchResultDto
 47            {
 48                Id = a.Id,
 49                Title = a.Title ?? "Untitled",
 50                Slug = a.Slug,
 51                MatchSnippet = a.Title ?? string.Empty,
 52                MatchType = "title",
 53                LastModified = a.ModifiedAt ?? a.CreatedAt,
 54                AncestorPath = new List<BreadcrumbDto>()
 55            })
 56            .ToListAsync();
 57
 58        var bodyMatches = await readableWorldArticles
 59            .Where(a => a.Body != null && a.Body.ToLower().Contains(normalizedQuery))
 60            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 61            .Take(20)
 62            .Select(a => new
 63            {
 64                a.Id,
 65                a.Title,
 66                a.Slug,
 67                a.Body,
 68                LastModified = a.ModifiedAt ?? a.CreatedAt
 69            })
 70            .ToListAsync();
 71
 72        var bodyResults = bodyMatches.Select(a => new ArticleSearchResultDto
 73        {
 74            Id = a.Id,
 75            Title = a.Title ?? "Untitled",
 76            Slug = a.Slug,
 77            MatchSnippet = ExtractSnippet(a.Body ?? string.Empty, query, 100),
 78            MatchType = "content",
 79            LastModified = a.LastModified,
 80            AncestorPath = new List<BreadcrumbDto>()
 81        }).ToList();
 82
 83        var hashtagQuery = $"#{query}";
 84        var hashtagMatches = await readableWorldArticles
 85            .Where(a => a.Body != null && a.Body.Contains(hashtagQuery))
 86            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 87            .Take(20)
 88            .Select(a => new
 89            {
 90                a.Id,
 91                a.Title,
 92                a.Slug,
 93                a.Body,
 94                LastModified = a.ModifiedAt ?? a.CreatedAt
 95            })
 96            .ToListAsync();
 97
 98        var hashtagResults = hashtagMatches.Select(a => new ArticleSearchResultDto
 99        {
 100            Id = a.Id,
 101            Title = a.Title ?? "Untitled",
 102            Slug = a.Slug,
 103            MatchSnippet = ExtractSnippet(a.Body ?? string.Empty, hashtagQuery, 100),
 104            MatchType = "hashtag",
 105            LastModified = a.LastModified,
 106            AncestorPath = new List<BreadcrumbDto>()
 107        }).ToList();
 108
 109        var allResults = titleMatches.Concat(bodyResults).Concat(hashtagResults).ToList();
 110        var ancestorOptions = new HierarchyWalkOptions
 111        {
 112            IncludeWorldBreadcrumb = false,
 113            IncludeCurrentArticle = false
 114        };
 115
 116        var ancestorPaths = await _hierarchyService.BuildBreadcrumbsBatchAsync(
 117            allResults.Select(r => r.Id),
 118            ancestorOptions);
 119
 120        foreach (var result in allResults)
 121        {
 122            if (ancestorPaths.TryGetValue(result.Id, out var path))
 123                result.AncestorPath = path;
 124        }
 125
 126        var seenIds = new HashSet<Guid>();
 127        var deduplicatedTitleMatches = titleMatches.Where(m => seenIds.Add(m.Id)).ToList();
 128        var deduplicatedBodyMatches = bodyResults.Where(m => seenIds.Add(m.Id)).ToList();
 129        var deduplicatedHashtagMatches = hashtagResults.Where(m => seenIds.Add(m.Id)).ToList();
 130
 131        return new GlobalSearchResultsDto
 132        {
 133            Query = query,
 134            TitleMatches = deduplicatedTitleMatches,
 135            BodyMatches = deduplicatedBodyMatches,
 136            HashtagMatches = deduplicatedHashtagMatches,
 137            TotalResults = deduplicatedTitleMatches.Count + deduplicatedBodyMatches.Count + deduplicatedHashtagMatches.C
 138        };
 139    }
 140
 141    private static string ExtractSnippet(string text, string searchTerm, int contextLength)
 142    {
 4143        if (string.IsNullOrEmpty(text))
 144        {
 1145            return string.Empty;
 146        }
 147
 3148        var index = text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase);
 3149        if (index < 0)
 150        {
 2151            return text.Length > contextLength * 2
 2152                ? text[..(contextLength * 2)] + "..."
 2153                : text;
 154        }
 155
 1156        var start = Math.Max(0, index - contextLength);
 1157        var end = Math.Min(text.Length, index + searchTerm.Length + contextLength);
 1158        var snippet = text[start..end];
 159
 1160        if (start > 0)
 161        {
 1162            snippet = "..." + snippet;
 163        }
 164
 1165        if (end < text.Length)
 166        {
 1167            snippet += "...";
 168        }
 169
 1170        return CleanForDisplay(snippet);
 171    }
 172
 173    private static string CleanForDisplay(string text)
 174    {
 2175        text = HtmlTagRegex().Replace(text, " ");
 2176        text = WorldLinkRegex().Replace(text, "$1");
 2177        text = WhitespaceRegex().Replace(text, " ");
 2178        return text.Trim();
 179    }
 180
 181    [GeneratedRegex(@"<[^>]+>")]
 182    private static partial Regex HtmlTagRegex();
 183
 184    [GeneratedRegex(@"\[\[[a-fA-F0-9\-]{36}(?:\|([^\]]+))?\]\]")]
 185    private static partial Regex WorldLinkRegex();
 186
 187    [GeneratedRegex(@"\s+")]
 188    private static partial Regex WhitespaceRegex();
 189}
 190