< Summary

Information
Class: Chronicis.Client.Services.Tree.TreeMutations
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeMutations.cs
Line coverage
100%
Covered lines: 49
Uncovered lines: 0
Coverable lines: 49
Total lines: 526
Line coverage: 100%
Branch coverage
100%
Covered branches: 41
Total branches: 41
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SetNodeIndex(...)100%11100%
SetRefreshCallback(...)100%11100%
DetermineChildArticleType(...)100%99100%
IsDescendantOf(...)100%1212100%
UpdateNodeDisplay(...)100%44100%
UpdateNodeVisibility(...)100%44100%
IsValidArticle(...)100%44100%
CanAcceptChildren(...)100%44100%
IsValidDropTarget(...)100%44100%

File(s)

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

#LineLine coverage
 1using Chronicis.Client.Models;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.DTOs.Sessions;
 4using Chronicis.Shared.Enums;
 5
 6namespace Chronicis.Client.Services.Tree;
 7
 8/// <summary>
 9/// Handles CRUD operations for tree nodes: create, move, delete, and update.
 10/// This component makes API calls and coordinates with TreeUiState for post-mutation state updates.
 11/// </summary>
 12internal sealed class TreeMutations
 13{
 14    private readonly IArticleApiService _articleApi;
 15    private readonly ISessionApiService _sessionApi;
 16    private readonly IAppContextService _appContext;
 17    private readonly ILogger _logger;
 18
 19    // Shared node index (owned by TreeStateService, passed in)
 6520    private TreeNodeIndex _nodeIndex = new();
 21
 22    // Callback to trigger tree refresh after mutations
 23    private Func<Task>? _refreshCallback;
 24
 25    public TreeMutations(
 26        IArticleApiService articleApi,
 27        ISessionApiService sessionApi,
 28        IAppContextService appContext,
 29        ILogger logger)
 30    {
 6531        _articleApi = articleApi;
 6532        _sessionApi = sessionApi;
 6533        _appContext = appContext;
 6534        _logger = logger;
 6535    }
 36
 37    // ============================================
 38    // Initialization
 39    // ============================================
 40
 41    /// <summary>
 42    /// Sets the node index reference. Called after tree is built.
 43    /// </summary>
 44    public void SetNodeIndex(TreeNodeIndex nodeIndex)
 45    {
 7046        _nodeIndex = nodeIndex;
 7047    }
 48
 49    /// <summary>
 50    /// Sets the callback to invoke when tree needs to be refreshed after a mutation.
 51    /// </summary>
 52    public void SetRefreshCallback(Func<Task> refreshCallback)
 53    {
 6554        _refreshCallback = refreshCallback;
 6555    }
 56
 57    // ============================================
 58    // Create Operations
 59    // ============================================
 60
 61    /// <summary>
 62    /// Creates a new root-level article in the current world.
 63    /// </summary>
 64    /// <returns>The ID of the created article, or null if creation failed.</returns>
 65    public async Task<Guid?> CreateRootArticleAsync()
 66    {
 67        var worldId = _appContext.CurrentWorldId;
 68        if (!worldId.HasValue)
 69        {
 70            _logger.LogWarning("Cannot create root article: no world selected");
 71            return null;
 72        }
 73
 74        var createDto = new ArticleCreateDto
 75        {
 76            Title = string.Empty,
 77            Body = string.Empty,
 78            ParentId = null,
 79            WorldId = worldId,
 80            Type = ArticleType.WikiArticle,
 81            EffectiveDate = DateTime.Now
 82        };
 83
 84        var created = await _articleApi.CreateArticleAsync(createDto);
 85        if (created == null)
 86        {
 87            _logger.LogWarning("Failed to create root article");
 88            return null;
 89        }
 90
 91        // Refresh tree to show new article
 92        if (_refreshCallback != null)
 93        {
 94            await _refreshCallback();
 95        }
 96
 97        return created.Id;
 98    }
 99
 100    /// <summary>
 101    /// Creates a new child article under the specified parent.
 102    /// </summary>
 103    /// <param name="parentId">The parent node ID (can be article, arc, or virtual group).</param>
 104    /// <returns>The ID of the created article, or null if creation failed.</returns>
 105    public async Task<Guid?> CreateChildArticleAsync(Guid parentId)
 106    {
 107        if (!_nodeIndex.TryGetNode(parentId, out var parentNode) || parentNode == null)
 108        {
 109            _logger.LogWarning("Cannot create child: parent {ParentId} not found", parentId);
 110            return null;
 111        }
 112
 113        if (parentNode.NodeType == TreeNodeType.Arc)
 114        {
 115            return await CreateSessionUnderArcAsync(parentNode);
 116        }
 117
 118        // Determine article type based on parent
 119        var articleType = DetermineChildArticleType(parentNode);
 120
 121        // For virtual groups, parent is null (top-level in that category)
 122        Guid? actualParentId = parentNode.NodeType == TreeNodeType.VirtualGroup ? null : parentId;
 123
 124        var createDto = new ArticleCreateDto
 125        {
 126            Title = string.Empty,
 127            Body = string.Empty,
 128            ParentId = actualParentId,
 129            WorldId = parentNode.WorldId ?? _appContext.CurrentWorldId,
 130            CampaignId = parentNode.CampaignId,
 131            ArcId = parentNode.NodeType == TreeNodeType.Arc ? parentNode.Id : parentNode.ArcId,
 132            Type = articleType,
 133            EffectiveDate = DateTime.Now
 134        };
 135
 136        var created = await _articleApi.CreateArticleAsync(createDto);
 137        if (created == null)
 138        {
 139            _logger.LogWarning("Failed to create child article under {ParentId}", parentId);
 140            return null;
 141        }
 142
 143        // Refresh tree
 144        if (_refreshCallback != null)
 145        {
 146            await _refreshCallback();
 147        }
 148
 149        return created.Id;
 150    }
 151
 152    /// <summary>
 153    /// Determines the appropriate article type for a child based on its parent node.
 154    /// </summary>
 155    private static ArticleType DetermineChildArticleType(TreeNode parentNode)
 156    {
 6157        return parentNode.NodeType switch
 6158        {
 4159            TreeNodeType.VirtualGroup => parentNode.VirtualGroupType switch
 4160            {
 1161                VirtualGroupType.Wiki => ArticleType.WikiArticle,
 1162                VirtualGroupType.PlayerCharacters => ArticleType.Character,
 1163                VirtualGroupType.Uncategorized => ArticleType.Legacy,
 1164                _ => ArticleType.WikiArticle
 4165            },
 1166            TreeNodeType.Article => parentNode.ArticleType ?? ArticleType.WikiArticle,
 1167            _ => ArticleType.WikiArticle
 6168        };
 169    }
 170
 171    private async Task<Guid?> CreateSessionUnderArcAsync(TreeNode arcNode)
 172    {
 173        var now = DateTime.Now;
 174        var createDto = new SessionCreateDto
 175        {
 176            Name = now.ToString("yyyy-MM-dd"),
 177            SessionDate = now
 178        };
 179
 180        var created = await _sessionApi.CreateSessionAsync(arcNode.Id, createDto);
 181        if (created == null)
 182        {
 183            _logger.LogWarning("Failed to create session under arc {ArcId}", arcNode.Id);
 184            return null;
 185        }
 186
 187        if (_refreshCallback != null)
 188        {
 189            await _refreshCallback();
 190        }
 191
 192        return created.Id;
 193    }
 194
 195    // ============================================
 196    // Delete Operations
 197    // ============================================
 198
 199    /// <summary>
 200    /// Deletes an article and all its descendants.
 201    /// </summary>
 202    /// <param name="articleId">The article ID to delete.</param>
 203    /// <returns>True if deletion succeeded.</returns>
 204    public async Task<bool> DeleteArticleAsync(Guid articleId)
 205    {
 206        if (!_nodeIndex.TryGetNode(articleId, out var node) || node == null)
 207        {
 208            return false;
 209        }
 210
 211        if (node.NodeType != TreeNodeType.Article)
 212        {
 213            _logger.LogWarning("Cannot delete non-article node {NodeId}", articleId);
 214            return false;
 215        }
 216
 217        try
 218        {
 219            await _articleApi.DeleteArticleAsync(articleId);
 220
 221            // Refresh tree
 222            if (_refreshCallback != null)
 223            {
 224                await _refreshCallback();
 225            }
 226
 227            return true;
 228        }
 229        catch (Exception ex)
 230        {
 231            _logger.LogError(ex, "Failed to delete article {ArticleId}", articleId);
 232            return false;
 233        }
 234    }
 235
 236    // ============================================
 237    // Move Operations
 238    // ============================================
 239
 240    /// <summary>
 241    /// Moves an article to a new parent (or to root if newParentId is null).
 242    /// </summary>
 243    /// <param name="articleId">The article to move.</param>
 244    /// <param name="newParentId">The new parent ID, or null for root level.</param>
 245    /// <returns>True if move succeeded.</returns>
 246    public async Task<bool> MoveArticleAsync(Guid articleId, Guid? newParentId)
 247    {
 248        if (!_nodeIndex.TryGetNode(articleId, out var node) || node == null)
 249        {
 250            return false;
 251        }
 252
 253        if (node.NodeType != TreeNodeType.Article)
 254        {
 255            _logger.LogWarning("Cannot move non-article node");
 256            return false;
 257        }
 258
 259        // Check if target is a virtual group
 260        TreeNode? targetNode = null;
 261        if (newParentId.HasValue)
 262        {
 263            _nodeIndex.TryGetNode(newParentId.Value, out targetNode);
 264        }
 265
 266        // Handle drop onto virtual group specially
 267        if (targetNode?.NodeType == TreeNodeType.VirtualGroup)
 268        {
 269            return await MoveToVirtualGroupAsync(articleId, node, targetNode);
 270        }
 271
 272        // Handle drop onto a Session entity (attach SessionNote to session root)
 273        if (targetNode?.NodeType == TreeNodeType.Session)
 274        {
 275            return await MoveToSessionAsync(articleId, node, targetNode);
 276        }
 277
 278        // Regular move to another article or root
 279        return await MoveToArticleOrRootAsync(articleId, node, newParentId, targetNode);
 280    }
 281
 282    /// <summary>
 283    /// Handles moving an article to a virtual group.
 284    /// </summary>
 285    private async Task<bool> MoveToVirtualGroupAsync(Guid articleId, TreeNode node, TreeNode targetNode)
 286    {
 287        // Campaigns group holds Campaign entities, not articles
 288        if (targetNode.VirtualGroupType == VirtualGroupType.Campaigns)
 289        {
 290            _logger.LogWarning("Cannot drop articles into Campaigns group - campaigns are separate entities");
 291            return false;
 292        }
 293
 294        // Links group holds external links, not articles
 295        if (targetNode.VirtualGroupType == VirtualGroupType.Links)
 296        {
 297            _logger.LogWarning("Cannot drop articles into Links group - links are separate entities");
 298            return false;
 299        }
 300
 301        // Determine new article type based on target group
 302        var newType = targetNode.VirtualGroupType switch
 303        {
 304            VirtualGroupType.Wiki => ArticleType.WikiArticle,
 305            VirtualGroupType.PlayerCharacters => ArticleType.Character,
 306            VirtualGroupType.Uncategorized => ArticleType.Legacy,
 307            _ => node.ArticleType ?? ArticleType.WikiArticle
 308        };
 309
 310        try
 311        {
 312            // First, move to root level (null parent)
 313            var moveSuccess = await _articleApi.MoveArticleAsync(articleId, null);
 314            if (!moveSuccess)
 315            {
 316                _logger.LogWarning("Failed to move article to root level");
 317                return false;
 318            }
 319
 320            // Then update the type if it changed
 321            if (node.ArticleType != newType)
 322            {
 323                var fullArticle = await _articleApi.GetArticleDetailAsync(articleId);
 324                if (fullArticle != null)
 325                {
 326                    var updateDto = new ArticleUpdateDto
 327                    {
 328                        Title = fullArticle.Title,
 329                        Body = fullArticle.Body,
 330                        Type = newType,
 331                        IconEmoji = fullArticle.IconEmoji,
 332                        EffectiveDate = fullArticle.EffectiveDate
 333                    };
 334
 335                    var updated = await _articleApi.UpdateArticleAsync(articleId, updateDto);
 336                    if (updated == null)
 337                    {
 338                        _logger.LogWarning("Failed to update article type");
 339                        // Move succeeded but type update failed - still refresh
 340                    }
 341                }
 342            }
 343
 344            if (_refreshCallback != null)
 345            {
 346                await _refreshCallback();
 347            }
 348
 349            return true;
 350        }
 351        catch (Exception ex)
 352        {
 353            _logger.LogError(ex, "Failed to move article to virtual group");
 354            return false;
 355        }
 356    }
 357
 358    /// <summary>
 359    /// Handles moving an article to another article or to root level.
 360    /// </summary>
 361    private async Task<bool> MoveToArticleOrRootAsync(Guid articleId, TreeNode node, Guid? newParentId, TreeNode? target
 362    {
 363        // Prevent moving to self or descendant
 364        if (newParentId.HasValue)
 365        {
 366            if (newParentId.Value == articleId)
 367            {
 368                _logger.LogWarning("Cannot move article to itself");
 369                return false;
 370            }
 371
 372            if (IsDescendantOf(newParentId.Value, articleId))
 373            {
 374                _logger.LogWarning("Cannot move article to its descendant");
 375                return false;
 376            }
 377
 378            // Ensure target is an article (not a World, Campaign, or Arc)
 379            if (targetNode != null && targetNode.NodeType != TreeNodeType.Article)
 380            {
 381                _logger.LogWarning("Cannot move article to non-article node type: {NodeType}", targetNode.NodeType);
 382                return false;
 383            }
 384        }
 385
 386        try
 387        {
 388            var success = await _articleApi.MoveArticleAsync(articleId, newParentId);
 389
 390            if (success && _refreshCallback != null)
 391            {
 392                await _refreshCallback();
 393            }
 394
 395            return success;
 396        }
 397        catch (Exception ex)
 398        {
 399            _logger.LogError(ex, "Failed to move article {ArticleId}", articleId);
 400            return false;
 401        }
 402    }
 403
 404    /// <summary>
 405    /// Handles moving a SessionNote article onto a Session entity node.
 406    /// </summary>
 407    private async Task<bool> MoveToSessionAsync(Guid articleId, TreeNode node, TreeNode targetSessionNode)
 408    {
 409        if (node.ArticleType != ArticleType.SessionNote)
 410        {
 411            _logger.LogWarning("Only SessionNote articles can be dropped onto a Session node");
 412            return false;
 413        }
 414
 415        try
 416        {
 417            var success = await _articleApi.MoveArticleAsync(articleId, null, targetSessionNode.Id);
 418
 419            if (success && _refreshCallback != null)
 420            {
 421                await _refreshCallback();
 422            }
 423
 424            return success;
 425        }
 426        catch (Exception ex)
 427        {
 428            _logger.LogError(ex, "Failed to move SessionNote {ArticleId} to session {SessionId}", articleId, targetSessi
 429            return false;
 430        }
 431    }
 432
 433    /// <summary>
 434    /// Checks if a node is a descendant of a potential ancestor.
 435    /// </summary>
 436    public bool IsDescendantOf(Guid nodeId, Guid potentialAncestorId)
 437    {
 12438        if (!_nodeIndex.TryGetNode(nodeId, out var node) || node == null)
 2439            return false;
 440
 10441        var current = node;
 11442        while (current.ParentId.HasValue)
 443        {
 5444            if (current.ParentId.Value == potentialAncestorId)
 3445                return true;
 446
 2447            if (!_nodeIndex.TryGetNode(current.ParentId.Value, out var parent) || parent == null)
 448                break;
 449
 1450            current = parent;
 451        }
 452
 7453        return false;
 454    }
 455
 456    // ============================================
 457    // Update Operations
 458    // ============================================
 459
 460    /// <summary>
 461    /// Updates a node's display properties (title, icon) after an article is saved.
 462    /// This is a local-only update; does not make API calls.
 463    /// </summary>
 464    /// <returns>True if the node was found and updated.</returns>
 465    public bool UpdateNodeDisplay(Guid nodeId, string title, string? iconEmoji)
 466    {
 4467        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 468        {
 2469            node.Title = title;
 2470            node.IconEmoji = iconEmoji;
 2471            return true;
 472        }
 2473        return false;
 474    }
 475
 476    /// <summary>
 477    /// Updates a node's visibility after privacy is toggled.
 478    /// This is a local-only update; does not make API calls.
 479    /// </summary>
 480    /// <returns>True if the node was found and updated.</returns>
 481    public bool UpdateNodeVisibility(Guid nodeId, ArticleVisibility visibility)
 482    {
 4483        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 484        {
 2485            node.Visibility = visibility;
 2486            return true;
 487        }
 2488        return false;
 489    }
 490
 491    // ============================================
 492    // Validation Helpers
 493    // ============================================
 494
 495    /// <summary>
 496    /// Checks if a node exists and is an article.
 497    /// </summary>
 498    public bool IsValidArticle(Guid nodeId)
 499    {
 3500        return _nodeIndex.TryGetNode(nodeId, out var node) &&
 3501               node != null &&
 3502               node.NodeType == TreeNodeType.Article;
 503    }
 504
 505    /// <summary>
 506    /// Checks if a node can accept children.
 507    /// </summary>
 508    public bool CanAcceptChildren(Guid nodeId)
 509    {
 3510        if (!_nodeIndex.TryGetNode(nodeId, out var node) || node == null)
 1511            return false;
 512
 2513        return node.CanAddChildren;
 514    }
 515
 516    /// <summary>
 517    /// Checks if a node can be a drop target for drag-and-drop.
 518    /// </summary>
 519    public bool IsValidDropTarget(Guid nodeId)
 520    {
 3521        if (!_nodeIndex.TryGetNode(nodeId, out var node) || node == null)
 1522            return false;
 523
 2524        return node.IsDropTarget;
 525    }
 526}