| | | 1 | | using System.Net; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | |
| | | 4 | | namespace Chronicis.Api.Services; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Regex-based implementation of <see cref="IWikiLinkTitleRewriter"/>. |
| | | 8 | | /// Operates on HTML string content produced by the TipTap editor. |
| | | 9 | | /// No external HTML parser dependency — uses a single <see cref="GeneratedRegex"/> |
| | | 10 | | /// mirroring the span format used by the wiki-link TipTap extension. |
| | | 11 | | /// </summary> |
| | | 12 | | public sealed partial class WikiLinkTitleRewriter : IWikiLinkTitleRewriter |
| | | 13 | | { |
| | | 14 | | /// <inheritdoc/> |
| | | 15 | | public (string Body, bool Changed) Rewrite(string? body, Guid targetArticleId, string newTitle) |
| | | 16 | | { |
| | 16 | 17 | | if (string.IsNullOrEmpty(body)) |
| | 2 | 18 | | return (string.Empty, false); |
| | | 19 | | |
| | 14 | 20 | | var targetIdStr = targetArticleId.ToString("D"); |
| | 14 | 21 | | var encodedTitle = WebUtility.HtmlEncode(newTitle); |
| | 14 | 22 | | var changed = false; |
| | | 23 | | |
| | 14 | 24 | | var result = WikiLinkSpanRegex().Replace(body, match => |
| | 14 | 25 | | { |
| | 14 | 26 | | var attrs = match.Groups[1].Value; |
| | 14 | 27 | | var innerText = match.Groups[2].Value; |
| | 14 | 28 | | |
| | 14 | 29 | | // Must be a wiki-link type span |
| | 14 | 30 | | if (!attrs.Contains("data-type=\"wiki-link\"", StringComparison.OrdinalIgnoreCase)) |
| | 14 | 31 | | return match.Value; |
| | 14 | 32 | | |
| | 14 | 33 | | // Must target the renamed article (case-insensitive GUID match) |
| | 14 | 34 | | if (!attrs.Contains($"data-target-id=\"{targetIdStr}\"", StringComparison.OrdinalIgnoreCase)) |
| | 14 | 35 | | return match.Value; |
| | 14 | 36 | | |
| | 14 | 37 | | // Skip if user supplied a custom label — presence of data-display= disqualifies, |
| | 14 | 38 | | // regardless of value (including empty string). |
| | 14 | 39 | | if (attrs.Contains("data-display=", StringComparison.OrdinalIgnoreCase)) |
| | 14 | 40 | | return match.Value; |
| | 14 | 41 | | |
| | 14 | 42 | | // Skip map chips (wiki-link spans with data-map-id attribute) |
| | 14 | 43 | | if (attrs.Contains("data-map-id=", StringComparison.OrdinalIgnoreCase)) |
| | 14 | 44 | | return match.Value; |
| | 14 | 45 | | |
| | 14 | 46 | | // Skip spans explicitly marked broken |
| | 14 | 47 | | if (attrs.Contains("data-broken=\"true\"", StringComparison.OrdinalIgnoreCase)) |
| | 14 | 48 | | return match.Value; |
| | 14 | 49 | | |
| | 14 | 50 | | changed = true; |
| | 14 | 51 | | return $"<span{attrs}>{encodedTitle}</span>"; |
| | 14 | 52 | | }); |
| | | 53 | | |
| | 14 | 54 | | return (result, changed); |
| | | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <summary> |
| | | 58 | | /// Matches any <span> element, capturing the attribute block (group 1) and |
| | | 59 | | /// plain-text inner content (group 2). <c>[^<]*</c> in group 2 ensures spans |
| | | 60 | | /// with nested markup are not matched, keeping inner-text-only spans eligible. |
| | | 61 | | /// </summary> |
| | | 62 | | [GeneratedRegex(@"<span(\s[^>]*?)>([^<]*)</span>", RegexOptions.IgnoreCase)] |
| | | 63 | | private static partial Regex WikiLinkSpanRegex(); |
| | | 64 | | } |