< Summary

Information
Class: Chronicis.Client.Services.Tree.TreeDataBuilder
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeDataBuilder.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 269
Coverable lines: 269
Total lines: 479
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 84
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
get_NodeIndex()100%210%
get_CachedArticles()100%210%
BuildTreeAsync()0%506220%
BuildWorldNode(...)0%1332360%
BuildCampaignNode(...)0%620%
BuildArcNode(...)0%4260%
BuildArticleNodeWithChildren(...)0%2040%
CreateArticleNode(...)100%210%
CreateVirtualGroupNode(...)100%210%
GetDocumentIcon(...)0%272160%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeDataBuilder.cs

#LineLine coverage
 1using Chronicis.Client.Models;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4
 5namespace Chronicis.Client.Services.Tree;
 6
 7/// <summary>
 8/// Responsible for building the navigation tree structure from API data.
 9/// Fetches worlds, campaigns, arcs, and articles and constructs the hierarchical tree.
 10/// </summary>
 11internal sealed class TreeDataBuilder
 12{
 13    private readonly IArticleApiService _articleApi;
 14    private readonly IWorldApiService _worldApi;
 15    private readonly ICampaignApiService _campaignApi;
 16    private readonly IArcApiService _arcApi;
 17    private readonly ILogger _logger;
 18
 019    public TreeDataBuilder(
 020        IArticleApiService articleApi,
 021        IWorldApiService worldApi,
 022        ICampaignApiService campaignApi,
 023        IArcApiService arcApi,
 024        ILogger logger)
 25    {
 026        _articleApi = articleApi;
 027        _worldApi = worldApi;
 028        _campaignApi = campaignApi;
 029        _arcApi = arcApi;
 030        _logger = logger;
 031    }
 32
 33    /// <summary>
 34    /// Result of building the tree, containing the node index and cached articles.
 35    /// </summary>
 36    public sealed class BuildResult
 37    {
 038        public required TreeNodeIndex NodeIndex { get; init; }
 039        public required List<ArticleTreeDto> CachedArticles { get; init; }
 40    }
 41
 42    /// <summary>
 43    /// Builds the complete tree structure by fetching all data from APIs.
 44    /// </summary>
 45    public async Task<BuildResult> BuildTreeAsync()
 46    {
 047        var nodeIndex = new TreeNodeIndex();
 048        var stopwatch = System.Diagnostics.Stopwatch.StartNew();
 49
 50        // Phase 1: Fetch worlds and articles in parallel
 051        var worldsTask = _worldApi.GetWorldsAsync();
 052        var articlesTask = _articleApi.GetAllArticlesAsync();
 53
 054        await Task.WhenAll(worldsTask, articlesTask);
 55
 056        var worlds = worldsTask.Result;
 057        var allArticles = articlesTask.Result;
 58
 059        if (!worlds.Any())
 60        {
 061            return new BuildResult
 062            {
 063                NodeIndex = nodeIndex,
 064                CachedArticles = allArticles
 065            };
 66        }
 67
 68        // Phase 2: Fetch all world details in parallel
 069        _logger.LogDebug("Phase 2: Fetching world details in parallel...");
 070        var worldDetailTasks = worlds.Select(w => _worldApi.GetWorldAsync(w.Id)).ToList();
 071        var worldDetails = await Task.WhenAll(worldDetailTasks);
 72
 073        var worldDetailLookup = worldDetails
 74            .Where(wd => wd != null)
 75            .ToDictionary(wd => wd!.Id, wd => wd!);
 76
 077        _logger.LogDebug("Phase 2 complete: {Count} world details in {Ms}ms",
 078            worldDetailLookup.Count, stopwatch.ElapsedMilliseconds);
 79
 80        // Phase 3: Collect all campaigns and fetch arcs, links, documents in parallel
 081        var allCampaigns = worldDetails
 82            .Where(wd => wd != null)
 83            .SelectMany(wd => wd!.Campaigns ?? new List<CampaignDto>())
 084            .ToList();
 85
 086        _logger.LogDebug("Phase 3: Fetching arcs for {Count} campaigns, world links, and documents in parallel...", allC
 87
 088        var arcTasks = allCampaigns.Select(c => _arcApi.GetArcsByCampaignAsync(c.Id)).ToList();
 089        var linkTasks = worlds.Select(w => _worldApi.GetWorldLinksAsync(w.Id)).ToList();
 090        var documentTasks = worlds.Select(w => _worldApi.GetWorldDocumentsAsync(w.Id)).ToList();
 91
 092        await Task.WhenAll(
 093            Task.WhenAll(arcTasks),
 094            Task.WhenAll(linkTasks),
 095            Task.WhenAll(documentTasks)
 096        );
 97
 98        var arcResults = arcTasks.Select(t => t.Result).ToArray();
 99
 0100        var linksByWorld = new Dictionary<Guid, List<WorldLinkDto>>();
 0101        for (int i = 0; i < worlds.Count; i++)
 102        {
 0103            linksByWorld[worlds[i].Id] = linkTasks[i].Result;
 104        }
 105
 0106        var documentsByWorld = new Dictionary<Guid, List<WorldDocumentDto>>();
 0107        for (int i = 0; i < worlds.Count; i++)
 108        {
 0109            documentsByWorld[worlds[i].Id] = documentTasks[i].Result;
 110        }
 111
 0112        var arcsByCampaign = new Dictionary<Guid, List<ArcDto>>();
 0113        for (int i = 0; i < allCampaigns.Count; i++)
 114        {
 0115            arcsByCampaign[allCampaigns[i].Id] = arcResults[i];
 116        }
 117
 0118        _logger.LogDebug("Phase 3 complete: {Count} total arcs in {Ms}ms",
 119            arcResults.Sum(r => r.Count), stopwatch.ElapsedMilliseconds);
 120
 121        // Phase 4: Build the tree structure
 0122        _logger.LogDebug("Phase 4: Building tree structure...");
 123
 124        var articleIndex = allArticles.ToDictionary(a => a.Id);
 125
 126        foreach (var world in worlds.OrderBy(w => w.Name))
 127        {
 0128            if (!worldDetailLookup.TryGetValue(world.Id, out var worldDetail))
 129            {
 0130                _logger.LogWarning("World detail not found for {WorldId}", world.Id);
 0131                continue;
 132            }
 133
 0134            var worldLinks = linksByWorld.TryGetValue(world.Id, out var links) ? links : new List<WorldLinkDto>();
 0135            var worldDocuments = documentsByWorld.TryGetValue(world.Id, out var docs) ? docs : new List<WorldDocumentDto
 136
 0137            var worldNode = BuildWorldNode(
 0138                world,
 0139                worldDetail,
 0140                allArticles,
 0141                articleIndex,
 0142                arcsByCampaign,
 0143                worldLinks,
 0144                worldDocuments,
 0145                nodeIndex);
 146
 0147            nodeIndex.AddRootNode(worldNode);
 148        }
 149
 150        // Handle orphan articles (no world assigned)
 151        var orphanArticles = allArticles.Where(a => !a.WorldId.HasValue && a.ParentId == null).ToList();
 0152        if (orphanArticles.Any())
 153        {
 0154            _logger.LogWarning("{Count} articles have no world assigned", orphanArticles.Count);
 155
 0156            var orphanNode = CreateVirtualGroupNode(
 0157                VirtualGroupType.Uncategorized,
 0158                "Unassigned Articles",
 0159                null);
 160
 0161            foreach (var article in orphanArticles)
 162            {
 0163                var articleNode = CreateArticleNode(article);
 0164                orphanNode.Children.Add(articleNode);
 0165                nodeIndex.AddNode(articleNode);
 166            }
 167
 0168            if (orphanNode.Children.Any())
 169            {
 0170                orphanNode.ChildCount = orphanNode.Children.Count;
 0171                nodeIndex.AddRootNode(orphanNode);
 172            }
 173        }
 174
 0175        stopwatch.Stop();
 0176        _logger.LogDebug("Tree build complete in {Ms}ms total", stopwatch.ElapsedMilliseconds);
 177
 0178        return new BuildResult
 0179        {
 0180            NodeIndex = nodeIndex,
 0181            CachedArticles = allArticles
 0182        };
 0183    }
 184
 185    private TreeNode BuildWorldNode(
 186        WorldDto world,
 187        WorldDetailDto worldDetail,
 188        List<ArticleTreeDto> allArticles,
 189        Dictionary<Guid, ArticleTreeDto> articleIndex,
 190        Dictionary<Guid, List<ArcDto>> arcsByCampaign,
 191        List<WorldLinkDto> worldLinks,
 192        List<WorldDocumentDto> worldDocuments,
 193        TreeNodeIndex nodeIndex)
 194    {
 0195        var worldNode = new TreeNode
 0196        {
 0197            Id = world.Id,
 0198            NodeType = TreeNodeType.World,
 0199            Title = world.Name,
 0200            WorldId = world.Id,
 0201            IconEmoji = "fa-solid fa-globe"
 0202        };
 203
 0204        var campaigns = worldDetail.Campaigns ?? new List<CampaignDto>();
 0205        var worldArticles = allArticles.Where(a => a.WorldId == world.Id).ToList();
 206
 207        // Create virtual groups
 0208        var campaignsGroup = CreateVirtualGroupNode(VirtualGroupType.Campaigns, "Campaigns", world.Id);
 0209        var charactersGroup = CreateVirtualGroupNode(VirtualGroupType.PlayerCharacters, "Player Characters", world.Id);
 0210        var wikiGroup = CreateVirtualGroupNode(VirtualGroupType.Wiki, "Wiki", world.Id);
 0211        var uncategorizedGroup = CreateVirtualGroupNode(VirtualGroupType.Uncategorized, "Uncategorized", world.Id);
 212
 213        // Build Campaigns group
 0214        foreach (var campaign in campaigns.OrderBy(c => c.Name))
 215        {
 0216            var arcs = arcsByCampaign.TryGetValue(campaign.Id, out var campaignArcs)
 0217                ? campaignArcs
 0218                : new List<ArcDto>();
 219
 0220            var campaignNode = BuildCampaignNode(campaign, arcs, worldArticles, articleIndex, nodeIndex);
 0221            campaignsGroup.Children.Add(campaignNode);
 0222            nodeIndex.AddNode(campaignNode);
 223        }
 0224        campaignsGroup.ChildCount = campaignsGroup.Children.Count;
 225
 226        // Build Characters group
 0227        var characterArticles = worldArticles
 0228            .Where(a => a.Type == ArticleType.Character && a.ParentId == null)
 0229            .OrderBy(a => a.Title)
 0230            .ToList();
 231
 0232        foreach (var article in characterArticles)
 233        {
 0234            var articleNode = BuildArticleNodeWithChildren(article, worldArticles, articleIndex, nodeIndex);
 0235            charactersGroup.Children.Add(articleNode);
 236        }
 0237        charactersGroup.ChildCount = charactersGroup.Children.Count;
 238
 239        // Build Wiki group
 0240        var wikiArticles = worldArticles
 0241            .Where(a => a.Type == ArticleType.WikiArticle && a.ParentId == null)
 0242            .OrderBy(a => a.Title)
 0243            .ToList();
 244
 0245        foreach (var article in wikiArticles)
 246        {
 0247            var articleNode = BuildArticleNodeWithChildren(article, worldArticles, articleIndex, nodeIndex);
 0248            wikiGroup.Children.Add(articleNode);
 249        }
 0250        wikiGroup.ChildCount = wikiGroup.Children.Count;
 251
 252        // Build Links group
 0253        var linksGroup = CreateVirtualGroupNode(VirtualGroupType.Links, "External Resources", world.Id);
 0254        foreach (var link in worldLinks.OrderBy(l => l.Title))
 255        {
 0256            var linkNode = new TreeNode
 0257            {
 0258                Id = link.Id,
 0259                NodeType = TreeNodeType.ExternalLink,
 0260                Title = link.Title,
 0261                Url = link.Url,
 0262                WorldId = world.Id,
 0263                IconEmoji = "fa-solid fa-external-link-alt"
 0264            };
 0265            linksGroup.Children.Add(linkNode);
 0266            nodeIndex.AddNode(linkNode);
 267        }
 268
 0269        foreach (var document in worldDocuments.Where(d => d.ArticleId == null).OrderBy(d => d.Title))
 270        {
 0271            var documentNode = new TreeNode
 0272            {
 0273                Id = document.Id,
 0274                NodeType = TreeNodeType.ExternalLink,
 0275                Title = document.Title,
 0276                Url = null,
 0277                WorldId = world.Id,
 0278                IconEmoji = GetDocumentIcon(document.ContentType),
 0279                AdditionalData = new Dictionary<string, object>
 0280                {
 0281                    { "IsDocument", true },
 0282                    { "ContentType", document.ContentType },
 0283                    { "FileSizeBytes", document.FileSizeBytes },
 0284                    { "FileName", document.FileName }
 0285                }
 0286            };
 0287            linksGroup.Children.Add(documentNode);
 0288            nodeIndex.AddNode(documentNode);
 289        }
 0290        linksGroup.ChildCount = linksGroup.Children.Count;
 291
 292        // Build Uncategorized group
 0293        var uncategorizedArticles = worldArticles
 0294            .Where(a => a.ParentId == null &&
 0295                       a.Type != ArticleType.WikiArticle &&
 0296                       a.Type != ArticleType.Character &&
 0297                       a.Type != ArticleType.Session &&
 0298                       a.Type != ArticleType.SessionNote &&
 0299                       a.Type != ArticleType.CharacterNote)
 0300            .OrderBy(a => a.Title)
 0301            .ToList();
 302
 0303        foreach (var article in uncategorizedArticles)
 304        {
 0305            var articleNode = BuildArticleNodeWithChildren(article, worldArticles, articleIndex, nodeIndex);
 0306            uncategorizedGroup.Children.Add(articleNode);
 307        }
 0308        uncategorizedGroup.ChildCount = uncategorizedGroup.Children.Count;
 309
 310        // Add groups to world node
 0311        worldNode.Children.Add(campaignsGroup);
 0312        nodeIndex.AddNode(campaignsGroup);
 313
 0314        worldNode.Children.Add(charactersGroup);
 0315        nodeIndex.AddNode(charactersGroup);
 316
 0317        worldNode.Children.Add(wikiGroup);
 0318        nodeIndex.AddNode(wikiGroup);
 319
 0320        if (linksGroup.Children.Any())
 321        {
 0322            worldNode.Children.Add(linksGroup);
 0323            nodeIndex.AddNode(linksGroup);
 324        }
 325
 0326        if (uncategorizedGroup.Children.Any())
 327        {
 0328            worldNode.Children.Add(uncategorizedGroup);
 0329            nodeIndex.AddNode(uncategorizedGroup);
 330        }
 331
 0332        worldNode.ChildCount = worldNode.Children.Count;
 333
 0334        return worldNode;
 335    }
 336
 337    private TreeNode BuildCampaignNode(
 338        CampaignDto campaign,
 339        List<ArcDto> arcs,
 340        List<ArticleTreeDto> worldArticles,
 341        Dictionary<Guid, ArticleTreeDto> articleIndex,
 342        TreeNodeIndex nodeIndex)
 343    {
 0344        var campaignNode = new TreeNode
 0345        {
 0346            Id = campaign.Id,
 0347            NodeType = TreeNodeType.Campaign,
 0348            Title = campaign.Name,
 0349            WorldId = campaign.WorldId,
 0350            CampaignId = campaign.Id,
 0351            IconEmoji = "fa-solid fa-dungeon"
 0352        };
 353
 0354        foreach (var arc in arcs.OrderBy(a => a.SortOrder).ThenBy(a => a.Name))
 355        {
 0356            var arcNode = BuildArcNode(arc, worldArticles, articleIndex, nodeIndex);
 0357            campaignNode.Children.Add(arcNode);
 0358            nodeIndex.AddNode(arcNode);
 359        }
 360
 0361        campaignNode.ChildCount = campaignNode.Children.Count;
 362
 0363        return campaignNode;
 364    }
 365
 366    private TreeNode BuildArcNode(
 367        ArcDto arc,
 368        List<ArticleTreeDto> worldArticles,
 369        Dictionary<Guid, ArticleTreeDto> articleIndex,
 370        TreeNodeIndex nodeIndex)
 371    {
 0372        var arcNode = new TreeNode
 0373        {
 0374            Id = arc.Id,
 0375            NodeType = TreeNodeType.Arc,
 0376            Title = arc.Name,
 0377            CampaignId = arc.CampaignId,
 0378            ArcId = arc.Id,
 0379            IconEmoji = "fa-solid fa-book-open"
 0380        };
 381
 0382        var sessionArticles = worldArticles
 0383            .Where(a => a.ArcId == arc.Id && a.Type == ArticleType.Session)
 0384            .OrderBy(a => a.Title)
 0385            .ToList();
 386
 0387        foreach (var article in sessionArticles)
 388        {
 0389            var articleNode = BuildArticleNodeWithChildren(article, worldArticles, articleIndex, nodeIndex);
 0390            arcNode.Children.Add(articleNode);
 391        }
 392
 0393        arcNode.ChildCount = arcNode.Children.Count;
 394
 0395        return arcNode;
 396    }
 397
 398    private TreeNode BuildArticleNodeWithChildren(
 399        ArticleTreeDto article,
 400        List<ArticleTreeDto> allArticles,
 401        Dictionary<Guid, ArticleTreeDto> articleIndex,
 402        TreeNodeIndex nodeIndex)
 403    {
 0404        var node = CreateArticleNode(article);
 0405        nodeIndex.AddNode(node);
 406
 0407        var children = allArticles
 0408            .Where(a => a.ParentId == article.Id)
 0409            .OrderBy(a => a.Title)
 0410            .ToList();
 411
 0412        foreach (var child in children)
 413        {
 0414            var childNode = BuildArticleNodeWithChildren(child, allArticles, articleIndex, nodeIndex);
 0415            childNode.ParentId = node.Id;
 0416            node.Children.Add(childNode);
 417        }
 418
 0419        node.ChildCount = node.Children.Count;
 420
 0421        return node;
 422    }
 423
 424    /// <summary>
 425    /// Creates a TreeNode from an ArticleTreeDto.
 426    /// </summary>
 427    public static TreeNode CreateArticleNode(ArticleTreeDto article)
 428    {
 0429        return new TreeNode
 0430        {
 0431            Id = article.Id,
 0432            NodeType = TreeNodeType.Article,
 0433            ArticleType = article.Type,
 0434            Title = article.Title,
 0435            Slug = article.Slug,
 0436            IconEmoji = article.IconEmoji,
 0437            ParentId = article.ParentId,
 0438            WorldId = article.WorldId,
 0439            CampaignId = article.CampaignId,
 0440            ArcId = article.ArcId,
 0441            ChildCount = article.ChildCount,
 0442            Visibility = article.Visibility,
 0443            HasAISummary = article.HasAISummary
 0444        };
 445    }
 446
 447    /// <summary>
 448    /// Creates a virtual group node.
 449    /// </summary>
 450    public static TreeNode CreateVirtualGroupNode(VirtualGroupType groupType, string title, Guid? worldId)
 451    {
 0452        return new TreeNode
 0453        {
 0454            Id = Guid.NewGuid(),
 0455            NodeType = TreeNodeType.VirtualGroup,
 0456            VirtualGroupType = groupType,
 0457            Title = title,
 0458            WorldId = worldId
 0459        };
 460    }
 461
 462    /// <summary>
 463    /// Gets the appropriate icon for a document based on its content type.
 464    /// </summary>
 465    public static string GetDocumentIcon(string contentType)
 466    {
 0467        return contentType.ToLowerInvariant() switch
 0468        {
 0469            "application/pdf" => "fa-solid fa-file-pdf",
 0470            "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "fa-solid fa-file-word",
 0471            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "fa-solid fa-file-excel",
 0472            "application/vnd.openxmlformats-officedocument.presentationml.presentation" => "fa-solid fa-file-powerpoint"
 0473            "text/plain" => "fa-solid fa-file-lines",
 0474            "text/markdown" => "fa-solid fa-file-lines",
 0475            string ct when ct.StartsWith("image/") => "fa-solid fa-file-image",
 0476            _ => "fa-solid fa-file"
 0477        };
 478    }
 479}

Methods/Properties

.ctor(Chronicis.Client.Services.IArticleApiService,Chronicis.Client.Services.IWorldApiService,Chronicis.Client.Services.ICampaignApiService,Chronicis.Client.Services.IArcApiService,Microsoft.Extensions.Logging.ILogger)
get_NodeIndex()
get_CachedArticles()
BuildTreeAsync()
BuildWorldNode(Chronicis.Shared.DTOs.WorldDto,Chronicis.Shared.DTOs.WorldDetailDto,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.ArticleTreeDto>,System.Collections.Generic.Dictionary`2<System.Guid,Chronicis.Shared.DTOs.ArticleTreeDto>,System.Collections.Generic.Dictionary`2<System.Guid,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.ArcDto>>,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.WorldLinkDto>,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.WorldDocumentDto>,Chronicis.Client.Services.Tree.TreeNodeIndex)
BuildCampaignNode(Chronicis.Shared.DTOs.CampaignDto,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.ArcDto>,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.ArticleTreeDto>,System.Collections.Generic.Dictionary`2<System.Guid,Chronicis.Shared.DTOs.ArticleTreeDto>,Chronicis.Client.Services.Tree.TreeNodeIndex)
BuildArcNode(Chronicis.Shared.DTOs.ArcDto,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.ArticleTreeDto>,System.Collections.Generic.Dictionary`2<System.Guid,Chronicis.Shared.DTOs.ArticleTreeDto>,Chronicis.Client.Services.Tree.TreeNodeIndex)
BuildArticleNodeWithChildren(Chronicis.Shared.DTOs.ArticleTreeDto,System.Collections.Generic.List`1<Chronicis.Shared.DTOs.ArticleTreeDto>,System.Collections.Generic.Dictionary`2<System.Guid,Chronicis.Shared.DTOs.ArticleTreeDto>,Chronicis.Client.Services.Tree.TreeNodeIndex)
CreateArticleNode(Chronicis.Shared.DTOs.ArticleTreeDto)
CreateVirtualGroupNode(Chronicis.Client.Models.VirtualGroupType,System.String,System.Nullable`1<System.Guid>)
GetDocumentIcon(System.String)