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