| | | 1 | | using Chronicis.Api.Data; |
| | | 2 | | using Chronicis.Shared.Models; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | |
| | | 5 | | namespace Chronicis.Api.Services; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Propagates article title renames by rewriting wiki-link span inner text across all |
| | | 9 | | /// back-linked articles and by recording the previous title as an <see cref="ArticleAlias"/>. |
| | | 10 | | /// |
| | | 11 | | /// <para> |
| | | 12 | | /// Transaction note: the title save in <c>ArticlesController.UpdateArticle</c> commits |
| | | 13 | | /// before this service runs. If this cascade throws, the rename is already persisted and |
| | | 14 | | /// back-link text will be stale until a manual retry. This is accepted POC behaviour. |
| | | 15 | | /// </para> |
| | | 16 | | /// </summary> |
| | | 17 | | public sealed class ArticleRenameCascadeService : IArticleRenameCascadeService |
| | | 18 | | { |
| | | 19 | | private readonly ChronicisDbContext _db; |
| | | 20 | | private readonly IWikiLinkTitleRewriter _rewriter; |
| | | 21 | | private readonly ILogger<ArticleRenameCascadeService> _logger; |
| | | 22 | | |
| | | 23 | | public ArticleRenameCascadeService( |
| | | 24 | | ChronicisDbContext db, |
| | | 25 | | IWikiLinkTitleRewriter rewriter, |
| | | 26 | | ILogger<ArticleRenameCascadeService> logger) |
| | | 27 | | { |
| | 14 | 28 | | _db = db; |
| | 14 | 29 | | _rewriter = rewriter; |
| | 14 | 30 | | _logger = logger; |
| | 14 | 31 | | } |
| | | 32 | | |
| | | 33 | | /// <inheritdoc/> |
| | | 34 | | public async Task CascadeTitleChangeAsync( |
| | | 35 | | Guid renamedArticleId, |
| | | 36 | | string oldTitle, |
| | | 37 | | string newTitle, |
| | | 38 | | CancellationToken cancellationToken = default) |
| | | 39 | | { |
| | | 40 | | // Case-insensitive no-op guard |
| | | 41 | | if (string.Equals(oldTitle, newTitle, StringComparison.OrdinalIgnoreCase)) |
| | | 42 | | return; |
| | | 43 | | |
| | | 44 | | if (string.IsNullOrWhiteSpace(newTitle)) |
| | | 45 | | throw new ArgumentException("New title cannot be whitespace.", nameof(newTitle)); |
| | | 46 | | |
| | | 47 | | // Collect source article IDs where DisplayText is null (user accepted default title) |
| | | 48 | | var sourceIds = await _db.ArticleLinks |
| | | 49 | | .Where(al => al.TargetArticleId == renamedArticleId && al.DisplayText == null) |
| | | 50 | | .Select(al => al.SourceArticleId) |
| | | 51 | | .Distinct() |
| | | 52 | | .ToListAsync(cancellationToken); |
| | | 53 | | |
| | | 54 | | var sourceArticles = await _db.Articles |
| | | 55 | | .Where(a => sourceIds.Contains(a.Id)) |
| | | 56 | | .ToListAsync(cancellationToken); |
| | | 57 | | |
| | | 58 | | var rewriteCount = 0; |
| | | 59 | | foreach (var article in sourceArticles) |
| | | 60 | | { |
| | | 61 | | var (newBody, changed) = _rewriter.Rewrite(article.Body, renamedArticleId, newTitle); |
| | | 62 | | if (changed) |
| | | 63 | | { |
| | | 64 | | article.Body = newBody; |
| | | 65 | | // Intentionally NOT updating ModifiedAt / LastModifiedBy per spec. |
| | | 66 | | rewriteCount++; |
| | | 67 | | } |
| | | 68 | | } |
| | | 69 | | |
| | | 70 | | // Append alias for old title on the renamed article (case-insensitive dedup) |
| | | 71 | | var aliasAdded = false; |
| | | 72 | | if (!string.IsNullOrWhiteSpace(oldTitle)) |
| | | 73 | | { |
| | | 74 | | var normalised = oldTitle.ToUpperInvariant(); |
| | | 75 | | var exists = await _db.ArticleAliases |
| | | 76 | | .AnyAsync(a => a.ArticleId == renamedArticleId && |
| | | 77 | | a.AliasText.ToUpper() == normalised, |
| | | 78 | | cancellationToken); |
| | | 79 | | |
| | | 80 | | if (!exists) |
| | | 81 | | { |
| | | 82 | | _db.ArticleAliases.Add(new ArticleAlias |
| | | 83 | | { |
| | | 84 | | Id = Guid.NewGuid(), |
| | | 85 | | ArticleId = renamedArticleId, |
| | | 86 | | AliasText = oldTitle, |
| | | 87 | | CreatedAt = DateTime.UtcNow, |
| | | 88 | | }); |
| | | 89 | | aliasAdded = true; |
| | | 90 | | } |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | await _db.SaveChangesAsync(cancellationToken); |
| | | 94 | | |
| | | 95 | | _logger.LogTraceSanitized( |
| | | 96 | | "Cascade rename {ArticleId} '{Old}' -> '{New}' touched {Count} source article(s), alias added: {AliasAdded}" |
| | | 97 | | renamedArticleId, oldTitle, newTitle, rewriteCount, aliasAdded); |
| | | 98 | | } |
| | | 99 | | } |