< 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: 33
Uncovered lines: 0
Coverable lines: 33
Total lines: 234
Line coverage: 100%
Branch coverage
100%
Covered branches: 18
Total branches: 18
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%
ComputeSlugChain(...)100%88100%
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 Chronicis.Shared.Enums;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace Chronicis.Api.Services;
 8
 9public sealed partial class SearchReadService : ISearchReadService
 10{
 11    private readonly ChronicisDbContext _context;
 12    private readonly IArticleHierarchyService _hierarchyService;
 13    private readonly IReadAccessPolicyService _readAccessPolicy;
 14
 15    public SearchReadService(
 16        ChronicisDbContext context,
 17        IArticleHierarchyService hierarchyService,
 18        IReadAccessPolicyService readAccessPolicy)
 19    {
 1120        _context = context;
 1121        _hierarchyService = hierarchyService;
 1122        _readAccessPolicy = readAccessPolicy;
 1123    }
 24
 25    public async Task<GlobalSearchResultsDto> SearchAsync(string query, Guid userId)
 26    {
 27        if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
 28        {
 29            return new GlobalSearchResultsDto
 30            {
 31                Query = query ?? string.Empty,
 32                TitleMatches = new List<ArticleSearchResultDto>(),
 33                BodyMatches = new List<ArticleSearchResultDto>(),
 34                HashtagMatches = new List<ArticleSearchResultDto>(),
 35                TotalResults = 0
 36            };
 37        }
 38
 39        var normalizedQuery = query.ToLowerInvariant();
 40        var readableWorldArticles = _readAccessPolicy
 41            .ApplyAuthenticatedWorldArticleFilter(_context.Articles.AsNoTracking(), userId);
 42
 43        var titleMatches = await readableWorldArticles
 44            .Where(a => a.Title != null && a.Title.ToLower().Contains(normalizedQuery))
 45            .OrderBy(a => a.Title)
 46            .Take(20)
 47            .Select(a => new ArticleSearchResultDto
 48            {
 49                Id = a.Id,
 50                Title = a.Title ?? "Untitled",
 51                Slug = a.Slug,
 52                MatchSnippet = a.Title ?? string.Empty,
 53                MatchType = "title",
 54                LastModified = a.ModifiedAt ?? a.CreatedAt,
 55                AncestorPath = new List<BreadcrumbDto>(),
 56                Type = a.Type,
 57                WorldSlug = a.World != null ? a.World.Slug : string.Empty,
 58                CampaignSlug = a.Session != null ? a.Session.Arc.Campaign.Slug : null,
 59                ArcSlug = a.Session != null ? a.Session.Arc.Slug : null,
 60                SessionSlug = a.Session != null ? a.Session.Slug : null
 61            })
 62            .ToListAsync();
 63
 64        var bodyMatches = await readableWorldArticles
 65            .Where(a => a.Body != null && a.Body.ToLower().Contains(normalizedQuery))
 66            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 67            .Take(20)
 68            .Select(a => new
 69            {
 70                a.Id,
 71                a.Title,
 72                a.Slug,
 73                a.Body,
 74                LastModified = a.ModifiedAt ?? a.CreatedAt,
 75                a.Type,
 76                WorldSlug = a.World != null ? a.World.Slug : string.Empty,
 77                CampaignSlug = a.Session != null ? a.Session.Arc.Campaign.Slug : null,
 78                ArcSlug = a.Session != null ? a.Session.Arc.Slug : null,
 79                SessionSlug = a.Session != null ? a.Session.Slug : null
 80            })
 81            .ToListAsync();
 82
 83        var bodyResults = bodyMatches.Select(a => new ArticleSearchResultDto
 84        {
 85            Id = a.Id,
 86            Title = a.Title ?? "Untitled",
 87            Slug = a.Slug,
 88            MatchSnippet = ExtractSnippet(a.Body ?? string.Empty, query, 100),
 89            MatchType = "content",
 90            LastModified = a.LastModified,
 91            AncestorPath = new List<BreadcrumbDto>(),
 92            Type = a.Type,
 93            WorldSlug = a.WorldSlug,
 94            CampaignSlug = a.CampaignSlug,
 95            ArcSlug = a.ArcSlug,
 96            SessionSlug = a.SessionSlug
 97        }).ToList();
 98
 99        var hashtagQuery = $"#{query}";
 100        var hashtagMatches = await readableWorldArticles
 101            .Where(a => a.Body != null && a.Body.Contains(hashtagQuery))
 102            .OrderByDescending(a => a.ModifiedAt ?? a.CreatedAt)
 103            .Take(20)
 104            .Select(a => new
 105            {
 106                a.Id,
 107                a.Title,
 108                a.Slug,
 109                a.Body,
 110                LastModified = a.ModifiedAt ?? a.CreatedAt,
 111                a.Type,
 112                WorldSlug = a.World != null ? a.World.Slug : string.Empty,
 113                CampaignSlug = a.Session != null ? a.Session.Arc.Campaign.Slug : null,
 114                ArcSlug = a.Session != null ? a.Session.Arc.Slug : null,
 115                SessionSlug = a.Session != null ? a.Session.Slug : null
 116            })
 117            .ToListAsync();
 118
 119        var hashtagResults = hashtagMatches.Select(a => new ArticleSearchResultDto
 120        {
 121            Id = a.Id,
 122            Title = a.Title ?? "Untitled",
 123            Slug = a.Slug,
 124            MatchSnippet = ExtractSnippet(a.Body ?? string.Empty, hashtagQuery, 100),
 125            MatchType = "hashtag",
 126            LastModified = a.LastModified,
 127            AncestorPath = new List<BreadcrumbDto>(),
 128            Type = a.Type,
 129            WorldSlug = a.WorldSlug,
 130            CampaignSlug = a.CampaignSlug,
 131            ArcSlug = a.ArcSlug,
 132            SessionSlug = a.SessionSlug
 133        }).ToList();
 134
 135        var allResults = titleMatches.Concat(bodyResults).Concat(hashtagResults).ToList();
 136        var ancestorOptions = new HierarchyWalkOptions
 137        {
 138            IncludeWorldBreadcrumb = false,
 139            IncludeCurrentArticle = false
 140        };
 141
 142        var ancestorPaths = await _hierarchyService.BuildBreadcrumbsBatchAsync(
 143            allResults.Select(r => r.Id),
 144            ancestorOptions);
 145
 146        foreach (var result in allResults)
 147        {
 148            if (ancestorPaths.TryGetValue(result.Id, out var path))
 149                result.AncestorPath = path;
 150            result.ArticleSlugChain = ComputeSlugChain(result);
 151        }
 152
 153        var seenIds = new HashSet<Guid>();
 154        var deduplicatedTitleMatches = titleMatches.Where(m => seenIds.Add(m.Id)).ToList();
 155        var deduplicatedBodyMatches = bodyResults.Where(m => seenIds.Add(m.Id)).ToList();
 156        var deduplicatedHashtagMatches = hashtagResults.Where(m => seenIds.Add(m.Id)).ToList();
 157
 158        return new GlobalSearchResultsDto
 159        {
 160            Query = query,
 161            TitleMatches = deduplicatedTitleMatches,
 162            BodyMatches = deduplicatedBodyMatches,
 163            HashtagMatches = deduplicatedHashtagMatches,
 164            TotalResults = deduplicatedTitleMatches.Count + deduplicatedBodyMatches.Count + deduplicatedHashtagMatches.C
 165        };
 166    }
 167
 168    private static List<string> ComputeSlugChain(ArticleSearchResultDto result)
 169    {
 10170        if (result.Type == ArticleType.SessionNote)
 171        {
 2172            if (result.CampaignSlug != null && result.ArcSlug != null && result.SessionSlug != null)
 1173                return [result.WorldSlug, result.CampaignSlug, result.ArcSlug, result.SessionSlug, result.Slug];
 1174            return [result.Slug];
 175        }
 176
 8177        var chain = result.AncestorPath
 8178            .Where(b => !b.IsWorld && b.Slug != "wiki")
 8179            .Select(b => b.Slug)
 8180            .ToList();
 8181        chain.Add(result.Slug);
 8182        return chain;
 183    }
 184
 185    private static string ExtractSnippet(string text, string searchTerm, int contextLength)
 186    {
 7187        if (string.IsNullOrEmpty(text))
 188        {
 1189            return string.Empty;
 190        }
 191
 6192        var index = text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase);
 6193        if (index < 0)
 194        {
 2195            return text.Length > contextLength * 2
 2196                ? text[..(contextLength * 2)] + "..."
 2197                : text;
 198        }
 199
 4200        var start = Math.Max(0, index - contextLength);
 4201        var end = Math.Min(text.Length, index + searchTerm.Length + contextLength);
 4202        var snippet = text[start..end];
 203
 4204        if (start > 0)
 205        {
 1206            snippet = "..." + snippet;
 207        }
 208
 4209        if (end < text.Length)
 210        {
 1211            snippet += "...";
 212        }
 213
 4214        return CleanForDisplay(snippet);
 215    }
 216
 217    private static string CleanForDisplay(string text)
 218    {
 5219        text = HtmlTagRegex().Replace(text, " ");
 5220        text = WorldLinkRegex().Replace(text, "$1");
 5221        text = WhitespaceRegex().Replace(text, " ");
 5222        return text.Trim();
 223    }
 224
 225    [GeneratedRegex(@"<[^>]+>")]
 226    private static partial Regex HtmlTagRegex();
 227
 228    [GeneratedRegex(@"\[\[[a-fA-F0-9\-]{36}(?:\|([^\]]+))?\]\]")]
 229    private static partial Regex WorldLinkRegex();
 230
 231    [GeneratedRegex(@"\s+")]
 232    private static partial Regex WhitespaceRegex();
 233}
 234