< Summary

Information
Class: Chronicis.Api.Services.AutoLinkService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/AutoLinkService.cs
Line coverage
100%
Covered lines: 18
Uncovered lines: 0
Coverable lines: 18
Total lines: 236
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
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%
GetProtectedRanges(...)100%88100%
IsInProtectedRange(...)100%66100%

File(s)

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

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Chronicis.Api.Data;
 3using Chronicis.Shared.DTOs;
 4using Microsoft.EntityFrameworkCore;
 5
 6namespace Chronicis.Api.Services;
 7
 8/// <summary>
 9/// Service for automatically detecting and inserting wiki links in article content.
 10/// </summary>
 11public interface IAutoLinkService
 12{
 13    /// <summary>
 14    /// Scans article content and returns match positions for wiki links.
 15    /// The client uses these positions to insert links via TipTap.
 16    /// </summary>
 17    /// <param name="articleId">The article being edited (to exclude from matches).</param>
 18    /// <param name="worldId">The world to search for matching articles.</param>
 19    /// <param name="body">The article body content (HTML) to scan.</param>
 20    /// <param name="userId">The user ID for scoping.</param>
 21    /// <returns>Response containing match positions and details.</returns>
 22    Task<AutoLinkResponseDto> FindLinksAsync(Guid articleId, Guid worldId, string body, Guid userId);
 23}
 24
 25/// <summary>
 26/// Implementation of auto-link service.
 27/// Works on HTML content and returns match positions for client-side insertion.
 28/// </summary>
 29public sealed partial class AutoLinkService : IAutoLinkService
 30{
 31    private readonly ChronicisDbContext _context;
 32    private readonly ILogger<AutoLinkService> _logger;
 33
 34    public AutoLinkService(ChronicisDbContext context, ILogger<AutoLinkService> logger)
 35    {
 136        _context = context;
 137        _logger = logger;
 138    }
 39
 40    public async Task<AutoLinkResponseDto> FindLinksAsync(
 41        Guid articleId,
 42        Guid worldId,
 43        string body,
 44        Guid userId)
 45    {
 46        if (string.IsNullOrWhiteSpace(body))
 47        {
 48            return new AutoLinkResponseDto
 49            {
 50                LinksFound = 0,
 51                Matches = new List<AutoLinkMatchDto>()
 52            };
 53        }
 54
 55        // Get all articles in this world that could be linked to (with their aliases)
 56        var linkableArticles = await (
 57            from a in _context.Articles
 58            join wm in _context.WorldMembers on a.WorldId equals wm.WorldId
 59            where wm.UserId == userId
 60            where a.WorldId == worldId
 61            where a.Id != articleId
 62            where !string.IsNullOrEmpty(a.Title)
 63            select new
 64            {
 65                a.Id,
 66                a.Title,
 67                Aliases = a.Aliases.Select(al => al.AliasText).ToList()
 68            }
 69        ).ToListAsync();
 70
 71        if (!linkableArticles.Any())
 72        {
 73            return new AutoLinkResponseDto
 74            {
 75                LinksFound = 0,
 76                Matches = new List<AutoLinkMatchDto>()
 77            };
 78        }
 79
 80        // Build protected ranges (areas we should not match in)
 81        var protectedRanges = GetProtectedRanges(body);
 82
 83        // Build a list of all searchable terms (titles + aliases) with their article info
 84        // Each term knows whether it's an alias or the canonical title
 85        var searchTerms = new List<(string Term, Guid ArticleId, string ArticleTitle, bool IsAlias)>();
 86
 87        foreach (var article in linkableArticles)
 88        {
 89            // Add the title
 90            searchTerms.Add((article.Title, article.Id, article.Title, false));
 91
 92            // Add all aliases
 93            foreach (var alias in article.Aliases)
 94            {
 95                if (!string.IsNullOrWhiteSpace(alias))
 96                {
 97                    searchTerms.Add((alias, article.Id, article.Title, true));
 98                }
 99            }
 100        }
 101
 102        // Sort by term length descending so we match longer terms first
 103        // This prevents "Water" from matching before "Waterdeep"
 104        var sortedTerms = searchTerms
 105            .OrderByDescending(t => t.Term.Length)
 106            .ToList();
 107
 108        var allMatches = new List<AutoLinkMatchDto>();
 109        var usedRanges = new List<(int Start, int End)>(); // Track ranges we've already matched
 110
 111        foreach (var term in sortedTerms)
 112        {
 113            // Build regex for whole-word, case-insensitive match
 114            var escapedTerm = Regex.Escape(term.Term);
 115            var pattern = $@"\b{escapedTerm}\b";
 116
 117            try
 118            {
 119                var regexMatches = Regex.Matches(body, pattern, RegexOptions.IgnoreCase);
 120
 121                foreach (Match match in regexMatches)
 122                {
 123                    // Skip if this position is in a protected range (HTML tag, existing link, etc.)
 124                    if (IsInProtectedRange(match.Index, match.Length, protectedRanges))
 125                    {
 126                        continue;
 127                    }
 128
 129                    // Skip if this position overlaps with an already-matched range
 130                    if (IsInProtectedRange(match.Index, match.Length, usedRanges))
 131                    {
 132                        continue;
 133                    }
 134
 135                    // Valid match - record it
 136                    allMatches.Add(new AutoLinkMatchDto
 137                    {
 138                        MatchedText = match.Value,
 139                        ArticleTitle = term.ArticleTitle,
 140                        ArticleId = term.ArticleId,
 141                        StartIndex = match.Index,
 142                        EndIndex = match.Index + match.Length,
 143                        IsAliasMatch = term.IsAlias
 144                    });
 145
 146                    // Mark this range as used so shorter terms don't match within it
 147                    usedRanges.Add((match.Index, match.Index + match.Length));
 148                }
 149            }
 150            catch (Exception ex)
 151            {
 152                _logger.LogWarningSanitized(ex, "Failed to create regex for term: {Term}", term.Term);
 153            }
 154        }
 155
 156        // Sort matches by position for consistent display in confirmation dialog
 157        allMatches = allMatches.OrderBy(m => m.StartIndex).ToList();
 158
 159        _logger.LogTraceSanitized(
 160            "Auto-link found {Count} matches for article {ArticleId}",
 161            allMatches.Count,
 162            articleId);
 163
 164        return new AutoLinkResponseDto
 165        {
 166            LinksFound = allMatches.Count,
 167            Matches = allMatches
 168        };
 169    }
 170
 171    /// <summary>
 172    /// Gets ranges in the content that should not be matched:
 173    /// - HTML tags
 174    /// - Existing wiki-link spans
 175    /// - Existing external-link spans
 176    /// - Legacy markdown wiki links
 177    /// </summary>
 178    private List<(int Start, int End)> GetProtectedRanges(string body)
 179    {
 1180        var ranges = new List<(int Start, int End)>();
 181
 182        // Protect HTML tags
 14183        foreach (Match match in HtmlTagRegex().Matches(body))
 184        {
 6185            ranges.Add((match.Index, match.Index + match.Length));
 186        }
 187
 188        // Protect existing wiki-link spans (entire span including content)
 4189        foreach (Match match in ExistingWikiLinkRegex().Matches(body))
 190        {
 1191            ranges.Add((match.Index, match.Index + match.Length));
 192        }
 193
 194        // Protect existing external-link spans
 4195        foreach (Match match in ExistingExternalLinkRegex().Matches(body))
 196        {
 1197            ranges.Add((match.Index, match.Index + match.Length));
 198        }
 199
 200        // Protect legacy markdown wiki links (for mixed content)
 4201        foreach (Match match in LegacyWikiLinkRegex().Matches(body))
 202        {
 1203            ranges.Add((match.Index, match.Index + match.Length));
 204        }
 205
 1206        return ranges;
 207    }
 208
 209    /// <summary>
 210    /// Checks if a position falls within any protected range.
 211    /// </summary>
 212    private bool IsInProtectedRange(int index, int length, List<(int Start, int End)> ranges)
 213    {
 23214        foreach (var range in ranges)
 215        {
 216            // Check if any part of the match overlaps with the protected range
 10217            if (index < range.End && index + length > range.Start)
 218            {
 1219                return true;
 220            }
 221        }
 1222        return false;
 1223    }
 224
 225    [GeneratedRegex(@"<[^>]+>")]
 226    private static partial Regex HtmlTagRegex();
 227
 228    [GeneratedRegex(@"<span[^>]*data-type=""wiki-link""[^>]*>.*?</span>", RegexOptions.Singleline)]
 229    private static partial Regex ExistingWikiLinkRegex();
 230
 231    [GeneratedRegex(@"<span[^>]*data-type=""external-link""[^>]*>.*?</span>", RegexOptions.Singleline)]
 232    private static partial Regex ExistingExternalLinkRegex();
 233
 234    [GeneratedRegex(@"\[\[([a-fA-F0-9\-]{36})(?:\|([^\]]+))?\]\]")]
 235    private static partial Regex LegacyWikiLinkRegex();
 236}