< 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
0%
Covered lines: 0
Uncovered lines: 102
Coverable lines: 102
Total lines: 246
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 32
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%
FindLinksAsync()0%342180%
GetProtectedRanges(...)0%7280%
IsInProtectedRange(...)0%4260%

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 class AutoLinkService : IAutoLinkService
 30{
 31    private readonly ChronicisDbContext _context;
 32    private readonly ILogger<AutoLinkService> _logger;
 33
 34    // Regex to find existing wiki-link spans in HTML
 35    // Matches: <span data-type="wiki-link" ... >...</span>
 036    private static readonly Regex ExistingWikiLinkPattern = new(
 037        @"<span[^>]*data-type=""wiki-link""[^>]*>.*?</span>",
 038        RegexOptions.Compiled | RegexOptions.Singleline);
 39
 40    // Regex to find existing external-link spans in HTML
 041    private static readonly Regex ExistingExternalLinkPattern = new(
 042        @"<span[^>]*data-type=""external-link""[^>]*>.*?</span>",
 043        RegexOptions.Compiled | RegexOptions.Singleline);
 44
 45    // Regex to find HTML tags (to avoid matching inside them)
 046    private static readonly Regex HtmlTagPattern = new(
 047        @"<[^>]+>",
 048        RegexOptions.Compiled);
 49
 50    // Legacy markdown wiki link pattern (for backwards compatibility)
 051    private static readonly Regex LegacyWikiLinkPattern = new(
 052        @"\[\[([a-fA-F0-9\-]{36})(?:\|([^\]]+))?\]\]",
 053        RegexOptions.Compiled);
 54
 055    public AutoLinkService(ChronicisDbContext context, ILogger<AutoLinkService> logger)
 56    {
 057        _context = context;
 058        _logger = logger;
 059    }
 60
 61    public async Task<AutoLinkResponseDto> FindLinksAsync(
 62        Guid articleId,
 63        Guid worldId,
 64        string body,
 65        Guid userId)
 66    {
 067        if (string.IsNullOrWhiteSpace(body))
 68        {
 069            return new AutoLinkResponseDto
 070            {
 071                LinksFound = 0,
 072                Matches = new List<AutoLinkMatchDto>()
 073            };
 74        }
 75
 76        // Get all articles in this world that could be linked to (with their aliases)
 077        var linkableArticles = await (
 078            from a in _context.Articles
 079            join wm in _context.WorldMembers on a.WorldId equals wm.WorldId
 080            where wm.UserId == userId
 081            where a.WorldId == worldId
 082            where a.Id != articleId
 083            where !string.IsNullOrEmpty(a.Title)
 084            select new
 085            {
 086                a.Id,
 087                a.Title,
 088                Aliases = a.Aliases.Select(al => al.AliasText).ToList()
 089            }
 090        ).ToListAsync();
 91
 092        if (!linkableArticles.Any())
 93        {
 094            return new AutoLinkResponseDto
 095            {
 096                LinksFound = 0,
 097                Matches = new List<AutoLinkMatchDto>()
 098            };
 99        }
 100
 101        // Build protected ranges (areas we should not match in)
 0102        var protectedRanges = GetProtectedRanges(body);
 103
 104        // Build a list of all searchable terms (titles + aliases) with their article info
 105        // Each term knows whether it's an alias or the canonical title
 0106        var searchTerms = new List<(string Term, Guid ArticleId, string ArticleTitle, bool IsAlias)>();
 107
 0108        foreach (var article in linkableArticles)
 109        {
 110            // Add the title
 0111            searchTerms.Add((article.Title, article.Id, article.Title, false));
 112
 113            // Add all aliases
 0114            foreach (var alias in article.Aliases)
 115            {
 0116                if (!string.IsNullOrWhiteSpace(alias))
 117                {
 0118                    searchTerms.Add((alias, article.Id, article.Title, true));
 119                }
 120            }
 121        }
 122
 123        // Sort by term length descending so we match longer terms first
 124        // This prevents "Water" from matching before "Waterdeep"
 0125        var sortedTerms = searchTerms
 0126            .OrderByDescending(t => t.Term.Length)
 0127            .ToList();
 128
 0129        var allMatches = new List<AutoLinkMatchDto>();
 0130        var usedRanges = new List<(int Start, int End)>(); // Track ranges we've already matched
 131
 0132        foreach (var term in sortedTerms)
 133        {
 134            // Build regex for whole-word, case-insensitive match
 0135            var escapedTerm = Regex.Escape(term.Term);
 0136            var pattern = $@"\b{escapedTerm}\b";
 137
 138            try
 139            {
 0140                var regex = new Regex(pattern, RegexOptions.IgnoreCase);
 0141                var regexMatches = regex.Matches(body);
 142
 0143                foreach (Match match in regexMatches)
 144                {
 145                    // Skip if this position is in a protected range (HTML tag, existing link, etc.)
 0146                    if (IsInProtectedRange(match.Index, match.Length, protectedRanges))
 147                    {
 148                        continue;
 149                    }
 150
 151                    // Skip if this position overlaps with an already-matched range
 0152                    if (IsInProtectedRange(match.Index, match.Length, usedRanges))
 153                    {
 154                        continue;
 155                    }
 156
 157                    // Valid match - record it
 0158                    allMatches.Add(new AutoLinkMatchDto
 0159                    {
 0160                        MatchedText = match.Value,
 0161                        ArticleTitle = term.ArticleTitle,
 0162                        ArticleId = term.ArticleId,
 0163                        StartIndex = match.Index,
 0164                        EndIndex = match.Index + match.Length,
 0165                        IsAliasMatch = term.IsAlias
 0166                    });
 167
 168                    // Mark this range as used so shorter terms don't match within it
 0169                    usedRanges.Add((match.Index, match.Index + match.Length));
 170                }
 0171            }
 0172            catch (Exception ex)
 173            {
 0174                _logger.LogWarning(ex, "Failed to create regex for term: {Term}", term.Term);
 0175            }
 176        }
 177
 178        // Sort matches by position for consistent display in confirmation dialog
 0179        allMatches = allMatches.OrderBy(m => m.StartIndex).ToList();
 180
 0181        _logger.LogDebug(
 0182            "Auto-link found {Count} matches for article {ArticleId}",
 0183            allMatches.Count,
 0184            articleId);
 185
 0186        return new AutoLinkResponseDto
 0187        {
 0188            LinksFound = allMatches.Count,
 0189            Matches = allMatches
 0190        };
 0191    }
 192
 193    /// <summary>
 194    /// Gets ranges in the content that should not be matched:
 195    /// - HTML tags
 196    /// - Existing wiki-link spans
 197    /// - Existing external-link spans
 198    /// - Legacy markdown wiki links
 199    /// </summary>
 200    private List<(int Start, int End)> GetProtectedRanges(string body)
 201    {
 0202        var ranges = new List<(int Start, int End)>();
 203
 204        // Protect HTML tags
 0205        foreach (Match match in HtmlTagPattern.Matches(body))
 206        {
 0207            ranges.Add((match.Index, match.Index + match.Length));
 208        }
 209
 210        // Protect existing wiki-link spans (entire span including content)
 0211        foreach (Match match in ExistingWikiLinkPattern.Matches(body))
 212        {
 0213            ranges.Add((match.Index, match.Index + match.Length));
 214        }
 215
 216        // Protect existing external-link spans
 0217        foreach (Match match in ExistingExternalLinkPattern.Matches(body))
 218        {
 0219            ranges.Add((match.Index, match.Index + match.Length));
 220        }
 221
 222        // Protect legacy markdown wiki links (for mixed content)
 0223        foreach (Match match in LegacyWikiLinkPattern.Matches(body))
 224        {
 0225            ranges.Add((match.Index, match.Index + match.Length));
 226        }
 227
 0228        return ranges;
 229    }
 230
 231    /// <summary>
 232    /// Checks if a position falls within any protected range.
 233    /// </summary>
 234    private bool IsInProtectedRange(int index, int length, List<(int Start, int End)> ranges)
 235    {
 0236        foreach (var range in ranges)
 237        {
 238            // Check if any part of the match overlaps with the protected range
 0239            if (index < range.End && index + length > range.Start)
 240            {
 0241                return true;
 242            }
 243        }
 0244        return false;
 0245    }
 246}