< Summary

Information
Class: Chronicis.Api.Controllers.ArticlesController
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/ArticlesController.cs
Line coverage
100%
Covered lines: 42
Uncovered lines: 0
Coverable lines: 42
Total lines: 741
Line coverage: 100%
Branch coverage
100%
Covered branches: 8
Total branches: 8
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
ParseAliases(...)100%22100%
ExtractMapFeatureIds(...)100%66100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Controllers/ArticlesController.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Chronicis.Api.Infrastructure;
 3using Chronicis.Api.Services;
 4using Chronicis.Api.Services.Articles;
 5using Chronicis.Shared.DTOs;
 6using Chronicis.Shared.Enums;
 7using Chronicis.Shared.Models;
 8using Chronicis.Shared.Utilities;
 9using Microsoft.AspNetCore.Authorization;
 10using Microsoft.AspNetCore.Mvc;
 11
 12namespace Chronicis.Api.Controllers;
 13
 14/// <summary>
 15/// API endpoints for Article operations.
 16/// </summary>
 17[ApiController]
 18[Route("articles")]
 19[Authorize]
 20public class ArticlesController : ControllerBase
 21{
 22    private readonly IArticleService _articleService;
 23    private readonly IArticleValidationService _validationService;
 24    private readonly ILinkSyncService _linkSyncService;
 25    private readonly IAutoLinkService _autoLinkService;
 26    private readonly IArticleExternalLinkService _externalLinkService;
 27    private readonly IArticleHierarchyService _hierarchyService;
 28    private readonly IArticleDataAccessService _articleDataAccessService;
 29    private readonly IWorldMapService _worldMapService;
 30    private readonly ICurrentUserService _currentUserService;
 31    private readonly IArticleRenameCascadeService _cascadeService;
 32    private readonly ILogger<ArticlesController> _logger;
 133    private static readonly Regex MapFeatureChipRegex = new(
 134        "data-type=\"map-feature-link\"[^>]*data-feature-id=\"([0-9a-fA-F-]{36})\"",
 135        RegexOptions.Compiled | RegexOptions.IgnoreCase);
 36
 937    public ArticlesController(
 938        IArticleService articleService,
 939        IArticleValidationService validationService,
 940        ILinkSyncService linkSyncService,
 941        IAutoLinkService autoLinkService,
 942        IArticleExternalLinkService externalLinkService,
 943        IArticleHierarchyService hierarchyService,
 944        IArticleDataAccessService articleDataAccessService,
 945        IWorldMapService worldMapService,
 946        ICurrentUserService currentUserService,
 947        IArticleRenameCascadeService cascadeService,
 948        ILogger<ArticlesController> logger)
 49    {
 950        _articleService = articleService;
 951        _validationService = validationService;
 952        _linkSyncService = linkSyncService;
 953        _autoLinkService = autoLinkService;
 954        _externalLinkService = externalLinkService;
 955        _hierarchyService = hierarchyService;
 956        _articleDataAccessService = articleDataAccessService;
 957        _worldMapService = worldMapService;
 958        _currentUserService = currentUserService;
 959        _cascadeService = cascadeService;
 960        _logger = logger;
 961    }
 62
 63    /// <summary>
 64    /// GET /api/articles - Returns all root-level articles (those without a parent).
 65    /// </summary>
 66    [HttpGet]
 67    public async Task<ActionResult<IEnumerable<ArticleTreeDto>>> GetRootArticles([FromQuery] Guid? worldId)
 68    {
 69        var user = await _currentUserService.GetRequiredUserAsync();
 70
 71        try
 72        {
 73            var articles = await _articleService.GetRootArticlesAsync(user.Id, worldId);
 74            return Ok(articles);
 75        }
 76        catch (Exception ex)
 77        {
 78            _logger.LogErrorSanitized(ex, "Error fetching root articles");
 79            return StatusCode(500, "Internal server error");
 80        }
 81    }
 82
 83    /// <summary>
 84    /// GET /api/articles/all - Returns all articles for the current user in a flat list.
 85    /// </summary>
 86    [HttpGet("all")]
 87    public async Task<ActionResult<IEnumerable<ArticleTreeDto>>> GetAllArticles([FromQuery] Guid? worldId)
 88    {
 89        var user = await _currentUserService.GetRequiredUserAsync();
 90
 91        try
 92        {
 93            var articles = await _articleService.GetAllArticlesAsync(user.Id, worldId);
 94            return Ok(articles);
 95        }
 96        catch (Exception ex)
 97        {
 98            _logger.LogErrorSanitized(ex, "Error fetching all articles");
 99            return StatusCode(500, "Internal server error");
 100        }
 101    }
 102
 103    /// <summary>
 104    /// GET /api/articles/{id} - Returns detailed information for a specific article.
 105    /// </summary>
 106    [HttpGet("{id:guid}")]
 107    public async Task<ActionResult<ArticleDto>> GetArticleDetail(Guid id)
 108    {
 109        var user = await _currentUserService.GetRequiredUserAsync();
 110
 111        try
 112        {
 113            var article = await _articleService.GetArticleDetailAsync(id, user.Id);
 114
 115            if (article == null)
 116            {
 117                return NotFound(new { message = $"Article {id} not found" });
 118            }
 119
 120            return Ok(article);
 121        }
 122        catch (Exception ex)
 123        {
 124            _logger.LogErrorSanitized(ex, "Error fetching article {ArticleId}", id);
 125            return StatusCode(500, "Internal server error");
 126        }
 127    }
 128
 129    /// <summary>
 130    /// GET /api/articles/{id}/children - Returns all child articles of the specified parent.
 131    /// </summary>
 132    [HttpGet("{id:guid}/children")]
 133    public async Task<ActionResult<IEnumerable<ArticleTreeDto>>> GetArticleChildren(Guid id)
 134    {
 135        var user = await _currentUserService.GetRequiredUserAsync();
 136
 137        try
 138        {
 139            var children = await _articleService.GetChildrenAsync(id, user.Id);
 140            return Ok(children);
 141        }
 142        catch (Exception ex)
 143        {
 144            _logger.LogErrorSanitized(ex, "Error fetching children for article {ParentId}", id);
 145            return StatusCode(500, "Internal server error");
 146        }
 147    }
 148
 149    /// <summary>
 150    /// GET /api/articles/by-path/{*path} - Gets an article by its URL path.
 151    /// </summary>
 152    [HttpGet("by-path/{*path}")]
 153    public async Task<ActionResult<ArticleDto>> GetArticleByPath(string path)
 154    {
 155        var user = await _currentUserService.GetRequiredUserAsync();
 156
 157        try
 158        {
 159            var article = await _articleService.GetArticleByPathAsync(path, user.Id);
 160
 161            if (article == null)
 162            {
 163                return NotFound(new { message = "Article not found" });
 164            }
 165
 166            return Ok(article);
 167        }
 168        catch (Exception ex)
 169        {
 170            _logger.LogErrorSanitized(ex, "Error fetching article by path: {Path}", path);
 171            return StatusCode(500, "Internal server error");
 172        }
 173    }
 174
 175    /// <summary>
 176    /// POST /api/articles - Creates a new article.
 177    /// </summary>
 178    [HttpPost]
 179    public async Task<ActionResult<ArticleDto>> CreateArticle([FromBody] ArticleCreateDto dto)
 180    {
 181        var user = await _currentUserService.GetRequiredUserAsync();
 182
 183        try
 184        {
 185            if (dto == null)
 186            {
 187                return BadRequest("Invalid request body");
 188            }
 189
 190            var validationResult = await _validationService.ValidateCreateAsync(dto);
 191            if (!validationResult.IsValid)
 192            {
 193                return BadRequest(new { errors = validationResult.Errors });
 194            }
 195
 196            if (dto.Type == ArticleType.Tutorial && !await _currentUserService.IsSysAdminAsync())
 197            {
 198                return Forbid();
 199            }
 200
 201            var normalizedWorldId = dto.Type == ArticleType.Tutorial
 202                ? Guid.Empty
 203                : dto.WorldId;
 204
 205            // Generate slug
 206            string slug;
 207            if (!string.IsNullOrWhiteSpace(dto.Slug))
 208            {
 209                if (!SlugGenerator.IsValidSlug(dto.Slug))
 210                {
 211                    return BadRequest("Slug must contain only lowercase letters, numbers, and hyphens");
 212                }
 213
 214                var isSlugUnique = dto.Type == ArticleType.Tutorial
 215                    ? await _articleDataAccessService.IsTutorialSlugUniqueAsync(dto.Slug, dto.ParentId)
 216                    : await _articleService.IsSlugUniqueAsync(dto.Slug, dto.ParentId, normalizedWorldId, user.Id, articl
 217
 218                if (!isSlugUnique)
 219                {
 220                    return Conflict($"An article with slug '{dto.Slug}' already exists in this location");
 221                }
 222
 223                slug = dto.Slug;
 224            }
 225            else
 226            {
 227                slug = dto.Type == ArticleType.Tutorial
 228                    ? await _articleDataAccessService.GenerateTutorialSlugAsync(dto.Title, dto.ParentId)
 229                    : await _articleService.GenerateUniqueSlugAsync(dto.Title, dto.ParentId, normalizedWorldId, user.Id,
 230            }
 231
 232            var article = new Article
 233            {
 234                Id = Guid.NewGuid(),
 235                Title = dto.Title,
 236                Slug = slug,
 237                ParentId = dto.ParentId,
 238                WorldId = normalizedWorldId,
 239                CampaignId = dto.CampaignId,
 240                ArcId = dto.ArcId,
 241                Body = dto.Body,
 242                Type = dto.Type,
 243                Visibility = dto.Visibility,
 244                CreatedAt = DateTime.UtcNow,
 245                CreatedBy = user.Id,
 246                EffectiveDate = dto.EffectiveDate ?? DateTime.UtcNow,
 247                IconEmoji = dto.IconEmoji,
 248                SessionDate = dto.SessionDate,
 249                InGameDate = dto.InGameDate,
 250                PlayerId = dto.PlayerId
 251            };
 252
 253            await _articleDataAccessService.AddArticleAsync(article);
 254
 255            // Sync wiki links if body contains content
 256            if (!string.IsNullOrEmpty(dto.Body))
 257            {
 258                await _linkSyncService.SyncLinksAsync(article.Id, dto.Body);
 259            }
 260
 261            if (article.Type == ArticleType.SessionNote)
 262            {
 263                await SyncSessionNoteMapFeaturesAsync(article.Id, article.WorldId, dto.Body, user.Id);
 264            }
 265
 266            var responseDto = new ArticleDto
 267            {
 268                Id = article.Id,
 269                Title = article.Title,
 270                Slug = article.Slug,
 271                ParentId = article.ParentId,
 272                WorldId = article.WorldId,
 273                CampaignId = article.CampaignId,
 274                ArcId = article.ArcId,
 275                Body = article.Body ?? string.Empty,
 276                Type = article.Type,
 277                Visibility = article.Visibility,
 278                CreatedAt = article.CreatedAt,
 279                ModifiedAt = article.ModifiedAt,
 280                EffectiveDate = article.EffectiveDate,
 281                CreatedBy = article.CreatedBy,
 282                IconEmoji = article.IconEmoji,
 283                HasChildren = false
 284            };
 285
 286            return CreatedAtAction(nameof(GetArticleDetail), new { id = article.Id }, responseDto);
 287        }
 288        catch (Exception ex)
 289        {
 290            _logger.LogErrorSanitized(ex, "Error creating article");
 291            return StatusCode(500, $"Error creating article: {ex.Message}");
 292        }
 293    }
 294
 295    /// <summary>
 296    /// PUT /api/articles/{id} - Updates an existing article.
 297    /// </summary>
 298    [HttpPut("{id:guid}")]
 299    public async Task<ActionResult<ArticleDto>> UpdateArticle(Guid id, [FromBody] ArticleUpdateDto dto)
 300    {
 301        var user = await _currentUserService.GetRequiredUserAsync();
 302
 303        try
 304        {
 305            if (dto == null)
 306            {
 307                return BadRequest("Invalid request body");
 308            }
 309
 310            var validationResult = await _validationService.ValidateUpdateAsync(id, dto);
 311            if (!validationResult.IsValid)
 312            {
 313                return BadRequest(new { errors = validationResult.Errors });
 314            }
 315
 316            var article = await _articleDataAccessService.FindReadableArticleAsync(id, user.Id);
 317
 318            if (article == null)
 319            {
 320                return NotFound($"Article {id} not found");
 321            }
 322
 323            var isTutorialArticle = article.Type == ArticleType.Tutorial;
 324            var targetType = dto.Type ?? article.Type;
 325            var targetIsTutorial = targetType == ArticleType.Tutorial;
 326
 327            if ((isTutorialArticle || targetIsTutorial) && !await _currentUserService.IsSysAdminAsync())
 328            {
 329                return Forbid();
 330            }
 331
 332            if (isTutorialArticle && dto.Type.HasValue && dto.Type.Value != ArticleType.Tutorial)
 333            {
 334                return BadRequest("Tutorial articles cannot be recategorized.");
 335            }
 336
 337            // Handle slug update if provided
 338            if (!string.IsNullOrWhiteSpace(dto.Slug) && dto.Slug != article.Slug)
 339            {
 340                if (!SlugGenerator.IsValidSlug(dto.Slug))
 341                {
 342                    return BadRequest("Slug must contain only lowercase letters, numbers, and hyphens");
 343                }
 344
 345                var isSlugUnique = targetIsTutorial
 346                    ? await _articleDataAccessService.IsTutorialSlugUniqueAsync(dto.Slug, article.ParentId, id)
 347                    : await _articleService.IsSlugUniqueAsync(dto.Slug, article.ParentId, article.WorldId, user.Id, id, 
 348
 349                if (!isSlugUnique)
 350                {
 351                    return Conflict($"An article with slug '{dto.Slug}' already exists in this location");
 352                }
 353
 354                article.Slug = dto.Slug;
 355            }
 356
 357            // Capture title before any mutation for cascade comparison
 358            var oldTitle = article.Title;
 359
 360            // Update fields
 361            if (dto.Title != null)
 362                article.Title = dto.Title;
 363            if (dto.Body != null)
 364                article.Body = dto.Body;
 365            if (dto.EffectiveDate.HasValue)
 366                article.EffectiveDate = dto.EffectiveDate.Value;
 367            if (dto.IconEmoji != null)
 368                article.IconEmoji = dto.IconEmoji;
 369            if (dto.SessionDate.HasValue)
 370                article.SessionDate = dto.SessionDate;
 371            if (dto.InGameDate != null)
 372                article.InGameDate = dto.InGameDate;
 373            if (dto.Visibility.HasValue)
 374                article.Visibility = dto.Visibility.Value;
 375            if (dto.Type.HasValue)
 376                article.Type = dto.Type.Value;
 377
 378            if (targetIsTutorial)
 379            {
 380                article.WorldId = Guid.Empty;
 381                article.CampaignId = null;
 382                article.ArcId = null;
 383                article.SessionId = null;
 384                if (!isTutorialArticle)
 385                {
 386                    article.ParentId = null;
 387                }
 388            }
 389
 390            article.ModifiedAt = DateTime.UtcNow;
 391            article.LastModifiedBy = user.Id;
 392
 393            await _articleDataAccessService.SaveChangesAsync();
 394
 395            // Sync wiki links after update
 396            if (!string.IsNullOrEmpty(dto.Body))
 397            {
 398                await _linkSyncService.SyncLinksAsync(id, dto.Body);
 399            }
 400
 401            // Sync external links after update
 402            await _externalLinkService.SyncExternalLinksAsync(id, dto.Body);
 403
 404            // Cascade title rename to backlinked article bodies (case-insensitive guard)
 405            if (dto.Title != null && !string.Equals(oldTitle, article.Title, StringComparison.OrdinalIgnoreCase))
 406            {
 407                await _cascadeService.CascadeTitleChangeAsync(id, oldTitle, article.Title);
 408            }
 409
 410            if (article.Type == ArticleType.SessionNote)
 411            {
 412                await SyncSessionNoteMapFeaturesAsync(id, article.WorldId, article.Body, user.Id);
 413            }
 414
 415            // Return updated article
 416            var updatedArticle = await _articleService.GetArticleDetailAsync(id, user.Id);
 417            return Ok(updatedArticle);
 418        }
 419        catch (Exception ex)
 420        {
 421            _logger.LogErrorSanitized(ex, "Error updating article {ArticleId}", id);
 422            return StatusCode(500, $"Error updating article: {ex.Message}");
 423        }
 424    }
 425
 426    /// <summary>
 427    /// DELETE /api/articles/{id} - Deletes an article and all its children.
 428    /// </summary>
 429    [HttpDelete("{id:guid}")]
 430    public async Task<IActionResult> DeleteArticle(Guid id)
 431    {
 432        var user = await _currentUserService.GetRequiredUserAsync();
 433
 434        try
 435        {
 436            var article = await _articleDataAccessService.FindReadableArticleAsync(id, user.Id);
 437
 438            if (article == null)
 439            {
 440                return NotFound($"Article {id} not found");
 441            }
 442
 443            if (article.Type == ArticleType.Tutorial && !await _currentUserService.IsSysAdminAsync())
 444            {
 445                return Forbid();
 446            }
 447
 448            // Delete all descendants recursively
 449            await _articleDataAccessService.DeleteArticleAndDescendantsAsync(id);
 450
 451            return NoContent();
 452        }
 453        catch (Exception ex)
 454        {
 455            _logger.LogErrorSanitized(ex, "Error deleting article {ArticleId}", id);
 456            return StatusCode(500, $"Error deleting article: {ex.Message}");
 457        }
 458    }
 459
 460    /// <summary>
 461    /// PUT /api/articles/{id}/move - Moves an article to a new parent.
 462    /// </summary>
 463    [HttpPut("{id:guid}/move")]
 464    public async Task<IActionResult> MoveArticle(Guid id, [FromBody] ArticleMoveDto dto)
 465    {
 466        var user = await _currentUserService.GetRequiredUserAsync();
 467
 468        try
 469        {
 470            if (dto == null)
 471            {
 472                return BadRequest("Invalid request body");
 473            }
 474
 475            var (success, errorMessage) = await _articleService.MoveArticleAsync(id, dto.NewParentId, dto.NewSessionId, 
 476
 477            if (!success)
 478            {
 479                return BadRequest(errorMessage);
 480            }
 481
 482            // Return the updated article
 483            var article = await _articleService.GetArticleDetailAsync(id, user.Id);
 484            return Ok(article);
 485        }
 486        catch (Exception ex)
 487        {
 488            _logger.LogErrorSanitized(ex, "Error moving article {ArticleId}", id);
 489            return StatusCode(500, $"Error moving article: {ex.Message}");
 490        }
 491    }
 492
 493    #region Aliases
 494
 495    /// <summary>
 496    /// PUT /api/articles/{id}/aliases - Updates all aliases for an article.
 497    /// Accepts a comma-delimited string that replaces all existing aliases.
 498    /// </summary>
 499    [HttpPut("{id:guid}/aliases")]
 500    public async Task<ActionResult<ArticleDto>> UpdateAliases(Guid id, [FromBody] ArticleAliasesUpdateDto dto)
 501    {
 502        var user = await _currentUserService.GetRequiredUserAsync();
 503
 504        try
 505        {
 506            if (dto == null)
 507            {
 508                return BadRequest("Invalid request body");
 509            }
 510
 511            var article = await _articleDataAccessService.GetReadableArticleWithAliasesAsync(id, user.Id);
 512
 513            if (article == null)
 514            {
 515                return NotFound($"Article {id} not found");
 516            }
 517
 518            if (article.Type == ArticleType.Tutorial && !await _currentUserService.IsSysAdminAsync())
 519            {
 520                return Forbid();
 521            }
 522
 523            // Parse the comma-delimited aliases
 524            var newAliases = ParseAliases(dto.Aliases);
 525
 526            // Validate: aliases cannot match the article's own title
 527            var titleLower = article.Title?.ToLowerInvariant() ?? string.Empty;
 528            var invalidAliases = newAliases.Where(a => a.ToLowerInvariant() == titleLower).ToList();
 529            if (invalidAliases.Any())
 530            {
 531                return BadRequest($"Alias cannot match the article's title: {string.Join(", ", invalidAliases)}");
 532            }
 533
 534            await _articleDataAccessService.UpsertAliasesAsync(article, newAliases, user.Id);
 535
 536            // Return updated article with aliases
 537            var updatedArticle = await _articleService.GetArticleDetailAsync(id, user.Id);
 538            return Ok(updatedArticle);
 539        }
 540        catch (Exception ex)
 541        {
 542            _logger.LogErrorSanitized(ex, "Error updating aliases for article {ArticleId}", id);
 543            return StatusCode(500, $"Error updating aliases: {ex.Message}");
 544        }
 545    }
 546
 547    /// <summary>
 548    /// Parses a comma-delimited string into a list of trimmed, non-empty, unique aliases.
 549    /// </summary>
 550    private static List<string> ParseAliases(string? aliasesString)
 551    {
 2552        if (string.IsNullOrWhiteSpace(aliasesString))
 553        {
 1554            return new List<string>();
 555        }
 556
 1557        return aliasesString
 1558            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1559            .Select(a => a.Trim())
 1560            .Where(a => !string.IsNullOrWhiteSpace(a) && a.Length <= 200)
 1561            .Distinct(StringComparer.OrdinalIgnoreCase)
 1562            .ToList();
 563    }
 564
 565    #endregion
 566
 567    #region Wiki Links
 568
 569    /// <summary>
 570    /// GET /articles/{id}/backlinks - Gets all articles that link to this article.
 571    /// </summary>
 572    [HttpGet("{id:guid}/backlinks")]
 573    public async Task<ActionResult<BacklinksResponseDto>> GetBacklinks(Guid id)
 574    {
 575        var user = await _currentUserService.GetRequiredUserAsync();
 576        _logger.LogTraceSanitized("Getting backlinks for article {ArticleId}", id);
 577
 578        var article = await _articleDataAccessService.FindReadableArticleAsync(id, user.Id);
 579
 580        if (article == null)
 581        {
 582            return NotFound(new { error = "Article not found or access denied" });
 583        }
 584
 585        // Get all articles that link TO this article
 586        var backlinks = await _articleDataAccessService.GetBacklinksAsync(id);
 587
 588        // Build display paths using centralised hierarchy service
 589        foreach (var backlink in backlinks)
 590        {
 591            backlink.DisplayPath = await _hierarchyService.BuildDisplayPathAsync(backlink.ArticleId);
 592        }
 593
 594        return Ok(new BacklinksResponseDto { Backlinks = backlinks });
 595    }
 596
 597    /// <summary>
 598    /// GET /articles/{id}/outgoing-links - Gets all articles that this article links to.
 599    /// </summary>
 600    [HttpGet("{id:guid}/outgoing-links")]
 601    public async Task<ActionResult<BacklinksResponseDto>> GetOutgoingLinks(Guid id)
 602    {
 603        var user = await _currentUserService.GetRequiredUserAsync();
 604        _logger.LogTraceSanitized("Getting outgoing links for article {ArticleId}", id);
 605
 606        var article = await _articleDataAccessService.FindReadableArticleAsync(id, user.Id);
 607
 608        if (article == null)
 609        {
 610            return NotFound(new { error = "Article not found or access denied" });
 611        }
 612
 613        // Get all articles that this article links TO
 614        var outgoingLinks = await _articleDataAccessService.GetOutgoingLinksAsync(id);
 615
 616        // Build display paths using centralised hierarchy service
 617        foreach (var link in outgoingLinks)
 618        {
 619            link.DisplayPath = await _hierarchyService.BuildDisplayPathAsync(link.ArticleId);
 620        }
 621
 622        return Ok(new BacklinksResponseDto { Backlinks = outgoingLinks });
 623    }
 624
 625    /// <summary>
 626    /// POST /articles/resolve-links - Resolves multiple article IDs to check if they exist.
 627    /// </summary>
 628    [HttpPost("resolve-links")]
 629    public async Task<ActionResult<LinkResolutionResponseDto>> ResolveLinks([FromBody] LinkResolutionRequestDto request)
 630    {
 631        var user = await _currentUserService.GetRequiredUserAsync();
 632
 633        if (request?.ArticleIds == null || !request.ArticleIds.Any())
 634        {
 635            return Ok(new LinkResolutionResponseDto { Articles = new Dictionary<Guid, ResolvedLinkDto>() });
 636        }
 637
 638        _logger.LogTraceSanitized("Resolving {Count} article links", request.ArticleIds.Count);
 639
 640        // Get all requested articles that the user has access to
 641        var articles = await _articleDataAccessService.ResolveReadableLinksAsync(request.ArticleIds, user.Id);
 642
 643        // Build response dictionary
 644        var result = new LinkResolutionResponseDto
 645        {
 646            Articles = new Dictionary<Guid, ResolvedLinkDto>()
 647        };
 648
 649        // Add found articles
 650        foreach (var article in articles)
 651        {
 652            result.Articles[article.ArticleId] = article;
 653        }
 654
 655        // Add missing articles as non-existent
 656        foreach (var requestedId in request.ArticleIds)
 657        {
 658            if (!result.Articles.ContainsKey(requestedId))
 659            {
 660                result.Articles[requestedId] = new ResolvedLinkDto
 661                {
 662                    ArticleId = requestedId,
 663                    Exists = false,
 664                    Title = null,
 665                    Slug = null
 666                };
 667            }
 668        }
 669
 670        return Ok(result);
 671    }
 672
 673    /// <summary>
 674    /// POST /articles/{id}/auto-link - Scans article content and returns match positions for wiki links.
 675    /// </summary>
 676    [HttpPost("{id:guid}/auto-link")]
 677    public async Task<ActionResult<AutoLinkResponseDto>> AutoLink(Guid id, [FromBody] AutoLinkRequestDto request)
 678    {
 679        var user = await _currentUserService.GetRequiredUserAsync();
 680
 681        if (request == null || string.IsNullOrEmpty(request.Body))
 682        {
 683            return BadRequest(new { error = "Body content is required" });
 684        }
 685
 686        _logger.LogTraceSanitized("Auto-linking article {ArticleId}", id);
 687
 688        // Get article and verify access
 689        var articleContext = await _articleDataAccessService.TryGetReadableArticleWorldAsync(id, user.Id);
 690        if (!articleContext.Found)
 691        {
 692            return NotFound(new { error = "Article not found or access denied" });
 693        }
 694
 695        if (!articleContext.WorldId.HasValue)
 696        {
 697            return BadRequest(new { error = "Article must belong to a world" });
 698        }
 699
 700        var result = await _autoLinkService.FindLinksAsync(
 701            id,
 702            articleContext.WorldId.Value,
 703            request.Body,
 704            user.Id);
 705
 706        return Ok(result);
 707    }
 708
 709    #endregion
 710
 711    private async Task SyncSessionNoteMapFeaturesAsync(Guid articleId, Guid? worldId, string? body, Guid userId)
 712    {
 713        if (!worldId.HasValue || worldId.Value == Guid.Empty)
 714        {
 715            return;
 716        }
 717
 718        var featureIds = ExtractMapFeatureIds(body);
 719        await _worldMapService.SyncSessionNoteMapFeaturesAsync(worldId.Value, articleId, featureIds, userId);
 720    }
 721
 722    private static List<Guid> ExtractMapFeatureIds(string? body)
 723    {
 2724        if (string.IsNullOrWhiteSpace(body))
 725        {
 1726            return [];
 727        }
 728
 1729        var featureIds = new List<Guid>();
 4730        foreach (Match match in MapFeatureChipRegex.Matches(body))
 731        {
 1732            if (Guid.TryParse(match.Groups[1].Value, out var featureId))
 733            {
 1734                featureIds.Add(featureId);
 735            }
 736        }
 737
 1738        return featureIds;
 739    }
 740
 741}