< Summary

Line coverage
100%
Covered lines: 121
Uncovered lines: 0
Coverable lines: 121
Total lines: 420
Line coverage: 100%
Branch coverage
100%
Covered branches: 40
Total branches: 40
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExportService.cs

#LineLine coverage
 1using System.IO.Compression;
 2using Chronicis.Api.Data;
 3using Chronicis.Shared.Enums;
 4using Chronicis.Shared.Models;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace Chronicis.Api.Services;
 8
 9/// <summary>
 10/// Service for exporting world data to downloadable archives
 11/// </summary>
 12public sealed partial class ExportService : IExportService
 13{
 14    private readonly ChronicisDbContext _db;
 15    private readonly IWorldMembershipService _membershipService;
 16    private readonly ILogger<ExportService> _logger;
 17
 18    public ExportService(
 19        ChronicisDbContext db,
 20        IWorldMembershipService membershipService,
 21        ILogger<ExportService> logger)
 22    {
 2123        _db = db;
 2124        _membershipService = membershipService;
 2125        _logger = logger;
 2126    }
 27
 28    public async Task<byte[]?> ExportWorldToMarkdownAsync(Guid worldId, Guid userId)
 29    {
 30        // Verify user has access to the world
 31        if (!await _membershipService.UserHasAccessAsync(worldId, userId))
 32        {
 33            _logger.LogWarningSanitized("User {UserId} attempted to export world {WorldId} without access", userId, worl
 34            return null;
 35        }
 36
 37        var world = await _db.Worlds
 38            .AsNoTracking()
 39            .FirstOrDefaultAsync(w => w.Id == worldId);
 40
 41        if (world == null)
 42        {
 43            _logger.LogWarningSanitized("World {WorldId} not found for export", worldId);
 44            return null;
 45        }
 46
 47        _logger.LogTraceSanitized("Starting export of world {WorldId} ({WorldName}) for user {UserId}",
 48            worldId, world.Name, userId);
 49
 50        // Get all articles for this world with their hierarchy info
 51        var articles = await _db.Articles
 52            .AsNoTracking()
 53            .Where(a => a.WorldId == worldId)
 54            .OrderBy(a => a.Title)
 55            .ToListAsync();
 56
 57        // Get campaigns and arcs for building folder structure
 58        var campaigns = await _db.Campaigns
 59            .AsNoTracking()
 60            .Where(c => c.WorldId == worldId)
 61            .ToListAsync();
 62
 63        var arcs = await _db.Arcs
 64            .AsNoTracking()
 65            .Where(a => campaigns.Select(c => c.Id).Contains(a.CampaignId))
 66            .ToListAsync();
 67
 68        var arcIds = arcs.Select(a => a.Id).ToList();
 69        var sessionEntities = await _db.Sessions
 70            .AsNoTracking()
 71            .Where(s => arcIds.Contains(s.ArcId))
 72            .ToListAsync();
 73
 74        // Pre-build lookups so hierarchy traversal and session-note resolution are O(1)
 75        // instead of O(N) per article/session, avoiding O(N²) behaviour on large worlds.
 76        var wikiByParent = articles
 77            .Where(a => a.Type == ArticleType.WikiArticle || a.Type == ArticleType.Legacy)
 78            .ToLookup(a => a.ParentId);
 79        var charactersByParent = articles
 80            .Where(a => a.Type == ArticleType.Character)
 81            .ToLookup(a => a.ParentId);
 82        var sessionNotesBySession = articles
 83            .Where(a => a.Type == ArticleType.SessionNote)
 84            .ToLookup(a => a.SessionId);
 85        var sessionsByArc = sessionEntities
 86            .ToLookup(s => s.ArcId);
 87
 88        // Build the zip archive
 89        using var memoryStream = new MemoryStream();
 90        using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
 91        {
 92            var worldFolderName = SanitizeFileName(world.Name);
 93
 94            // Export wiki articles (hierarchical)
 95            await ExportArticleHierarchy(archive, wikiByParent, $"{worldFolderName}/Wiki", null);
 96
 97            // Export characters
 98            await ExportArticleHierarchy(archive, charactersByParent, $"{worldFolderName}/Characters", null);
 99
 100            // Export campaigns with their sessions
 101            var usedCampaignNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 102            foreach (var campaign in campaigns)
 103            {
 104                var campaignFolderName = GetUniqueSiblingName(campaign.Name, usedCampaignNames);
 105                var campaignFolder = $"{worldFolderName}/Campaigns/{campaignFolderName}";
 106
 107                // Campaign info file
 108                var campaignContent = BuildCampaignMarkdown(campaign);
 109                await AddFileToArchive(archive, $"{campaignFolder}/{campaignFolderName}.md", campaignContent);
 110
 111                // Export arcs and sessions
 112                var campaignArcs = arcs.Where(a => a.CampaignId == campaign.Id).OrderBy(a => a.SortOrder).ToList();
 113                var usedArcNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 114                foreach (var arc in campaignArcs)
 115                {
 116                    var arcFolderName = GetUniqueSiblingName(arc.Name, usedArcNames);
 117                    var arcFolder = $"{campaignFolder}/{arcFolderName}";
 118
 119                    // Arc info file
 120                    var arcContent = BuildArcMarkdown(arc);
 121                    await AddFileToArchive(archive, $"{arcFolder}/{arcFolderName}.md", arcContent);
 122
 123                    // Session entities in this arc
 124                    var arcSessions = sessionsByArc[arc.Id]
 125                        .OrderBy(s => s.SessionDate ?? s.CreatedAt)
 126                        .ThenBy(s => s.Name)
 127                        .ToList();
 128
 129                    var usedSessionFolderNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 130                    foreach (var session in arcSessions)
 131                    {
 132                        var sessionFolderName = GetUniqueSiblingName(session.Name, usedSessionFolderNames);
 133                        var sessionContent = BuildSessionMarkdown(session);
 134                        await AddFileToArchive(archive, $"{arcFolder}/{sessionFolderName}/{sessionFolderName}.md", sessi
 135
 136                        // Session notes: O(1) lookup instead of O(articles) scan
 137                        var sessionNotes = sessionNotesBySession[session.Id]
 138                            .OrderBy(a => a.CreatedAt)
 139                            .ThenBy(a => a.Title)
 140                            .ToList();
 141
 142                        var usedSessionNoteNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 143                        foreach (var note in sessionNotes)
 144                        {
 145                            var noteContent = BuildArticleMarkdown(note);
 146                            var noteFileName = GetUniqueSiblingName(note.Title, usedSessionNoteNames);
 147                            await AddFileToArchive(archive, $"{arcFolder}/{sessionFolderName}/{noteFileName}.md", noteCo
 148                        }
 149                    }
 150                }
 151            }
 152        }
 153
 154        memoryStream.Position = 0;
 155        var result = memoryStream.ToArray();
 156
 157        _logger.LogTraceSanitized("Export completed for world {WorldId}. Archive size: {Size} bytes",
 158            worldId, result.Length);
 159
 160        return result;
 161    }
 162
 163    private async Task ExportArticleHierarchy(
 164        ZipArchive archive,
 165        ILookup<Guid?, Article> byParent,
 166        string basePath,
 167        Guid? parentId)
 168    {
 169        var children = byParent[parentId].OrderBy(a => a.Title);
 170        var usedSiblingNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 171
 172        foreach (var article in children)
 173        {
 174            var articleFileName = GetUniqueSiblingName(article.Title, usedSiblingNames);
 175            var hasChildren = byParent[article.Id].Any();
 176
 177            if (hasChildren)
 178            {
 179                // Article with children: ArticleName/ArticleName.md
 180                var articleFolder = $"{basePath}/{articleFileName}";
 181                var content = BuildArticleMarkdown(article);
 182                await AddFileToArchive(archive, $"{articleFolder}/{articleFileName}.md", content);
 183
 184                // Recursively export children
 185                await ExportArticleHierarchy(archive, byParent, articleFolder, article.Id);
 186            }
 187            else
 188            {
 189                // Leaf article: ArticleName.md
 190                var content = BuildArticleMarkdown(article);
 191                await AddFileToArchive(archive, $"{basePath}/{articleFileName}.md", content);
 192            }
 193        }
 194    }
 195
 196}

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExportService.MarkdownBuilders.cs

#LineLine coverage
 1using System.Text;
 2using Chronicis.Shared.Models;
 3
 4namespace Chronicis.Api.Services;
 5
 6public partial class ExportService
 7{
 8    internal string BuildArticleMarkdown(Article article)
 9    {
 710        var sb = new StringBuilder();
 11
 712        sb.AppendLine("---");
 713        sb.AppendLine($"title: \"{EscapeYaml(article.Title)}\"");
 714        sb.AppendLine($"type: {article.Type}");
 715        sb.AppendLine($"visibility: {article.Visibility}");
 716        sb.AppendLine($"created: {article.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 717        if (article.ModifiedAt.HasValue)
 118            sb.AppendLine($"modified: {article.ModifiedAt:yyyy-MM-dd HH:mm:ss}");
 719        if (article.SessionDate.HasValue)
 120            sb.AppendLine($"session_date: {article.SessionDate:yyyy-MM-dd}");
 721        if (!string.IsNullOrEmpty(article.InGameDate))
 122            sb.AppendLine($"in_game_date: \"{EscapeYaml(article.InGameDate)}\"");
 723        if (!string.IsNullOrEmpty(article.IconEmoji))
 124            sb.AppendLine($"icon: \"{article.IconEmoji}\"");
 725        sb.AppendLine("---");
 726        sb.AppendLine();
 27
 728        sb.AppendLine($"# {article.Title}");
 729        sb.AppendLine();
 30
 731        if (!string.IsNullOrEmpty(article.Body))
 32        {
 533            sb.AppendLine(HtmlToMarkdownConverter.Convert(article.Body));
 34        }
 35
 736        AppendAISummary(sb, article);
 37
 738        return sb.ToString();
 39    }
 40
 41    private static void AppendAISummary(StringBuilder sb, Article article)
 42    {
 743        if (string.IsNullOrEmpty(article.AISummary))
 544            return;
 45
 246        sb.AppendLine();
 247        sb.AppendLine("---");
 248        sb.AppendLine();
 249        sb.AppendLine("## AI Summary");
 250        sb.AppendLine();
 251        sb.AppendLine(article.AISummary);
 52
 253        if (article.AISummaryGeneratedAt.HasValue)
 54        {
 155            sb.AppendLine();
 156            sb.AppendLine($"*Generated: {article.AISummaryGeneratedAt:yyyy-MM-dd HH:mm:ss}*");
 57        }
 258    }
 59
 60    internal string BuildCampaignMarkdown(Campaign campaign)
 61    {
 562        var sb = new StringBuilder();
 63
 564        sb.AppendLine("---");
 565        sb.AppendLine($"title: \"{EscapeYaml(campaign.Name)}\"");
 566        sb.AppendLine("type: Campaign");
 567        sb.AppendLine($"created: {campaign.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 568        sb.AppendLine("---");
 569        sb.AppendLine();
 570        sb.AppendLine($"# {campaign.Name}");
 571        sb.AppendLine();
 72
 573        if (!string.IsNullOrEmpty(campaign.Description))
 174            sb.AppendLine(campaign.Description);
 75
 576        return sb.ToString();
 77    }
 78
 79    internal string BuildSessionMarkdown(Session session)
 80    {
 581        var sb = new StringBuilder();
 82
 583        sb.AppendLine("---");
 584        sb.AppendLine($"title: \"{EscapeYaml(session.Name)}\"");
 585        sb.AppendLine("type: Session");
 586        sb.AppendLine($"created: {session.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 587        if (session.ModifiedAt.HasValue)
 88        {
 189            sb.AppendLine($"modified: {session.ModifiedAt:yyyy-MM-dd HH:mm:ss}");
 90        }
 91
 592        if (session.SessionDate.HasValue)
 93        {
 394            sb.AppendLine($"session_date: {session.SessionDate:yyyy-MM-dd}");
 95        }
 96
 597        sb.AppendLine("---");
 598        sb.AppendLine();
 599        sb.AppendLine($"# {session.Name}");
 5100        sb.AppendLine();
 101
 5102        if (!string.IsNullOrEmpty(session.PublicNotes))
 103        {
 5104            sb.AppendLine(HtmlToMarkdownConverter.Convert(session.PublicNotes));
 105        }
 106
 5107        AppendSessionAiSummary(sb, session);
 108
 5109        return sb.ToString();
 110    }
 111
 112    private static void AppendSessionAiSummary(StringBuilder sb, Session session)
 113    {
 5114        if (string.IsNullOrEmpty(session.AiSummary))
 115        {
 2116            return;
 117        }
 118
 3119        sb.AppendLine();
 3120        sb.AppendLine("---");
 3121        sb.AppendLine();
 3122        sb.AppendLine("## AI Summary");
 3123        sb.AppendLine();
 3124        sb.AppendLine(session.AiSummary);
 125
 3126        if (session.AiSummaryGeneratedAt.HasValue)
 127        {
 1128            sb.AppendLine();
 1129            sb.AppendLine($"*Generated: {session.AiSummaryGeneratedAt:yyyy-MM-dd HH:mm:ss}*");
 130        }
 3131    }
 132
 133    internal string BuildArcMarkdown(Arc arc)
 134    {
 5135        var sb = new StringBuilder();
 136
 5137        sb.AppendLine("---");
 5138        sb.AppendLine($"title: \"{EscapeYaml(arc.Name)}\"");
 5139        sb.AppendLine("type: Arc");
 5140        sb.AppendLine($"sort_order: {arc.SortOrder}");
 5141        sb.AppendLine($"created: {arc.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 5142        sb.AppendLine("---");
 5143        sb.AppendLine();
 5144        sb.AppendLine($"# {arc.Name}");
 5145        sb.AppendLine();
 146
 5147        if (!string.IsNullOrEmpty(arc.Description))
 1148            sb.AppendLine(arc.Description);
 149
 5150        return sb.ToString();
 151    }
 152}

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExportService.Utilities.cs

#LineLine coverage
 1using System.IO.Compression;
 2using System.Text;
 3
 4namespace Chronicis.Api.Services;
 5
 6public partial class ExportService
 7{
 8    private static async Task AddFileToArchive(ZipArchive archive, string path, string content)
 9    {
 10        var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
 11        using var stream = entry.Open();
 12        using var writer = new StreamWriter(stream, Encoding.UTF8);
 13        await writer.WriteAsync(content);
 14    }
 15
 16    internal static string SanitizeFileName(string name)
 17    {
 3618        if (string.IsNullOrWhiteSpace(name))
 219            return "Untitled";
 20
 3421        var invalid = Path.GetInvalidFileNameChars();
 3422        var sanitized = new StringBuilder(name);
 20423        foreach (var c in invalid)
 24        {
 6825            sanitized.Replace(c, '_');
 26        }
 27
 3428        sanitized.Replace('/', '_');
 3429        sanitized.Replace('\\', '_');
 3430        sanitized.Replace(':', '_');
 31
 3432        var result = sanitized.ToString().Trim();
 33
 3434        if (result.Length > 100)
 135            result = result[..100];
 36
 3437        return result;
 38    }
 39
 40    internal static string GetUniqueSiblingName(string desiredName, ISet<string> usedSiblingNames)
 41    {
 1542        var baseName = SanitizeFileName(desiredName);
 1543        if (usedSiblingNames.Add(baseName))
 44        {
 1245            return baseName;
 46        }
 47
 348        var suffix = 2;
 149        while (true)
 50        {
 451            var candidate = $"{baseName} ({suffix})";
 452            if (usedSiblingNames.Add(candidate))
 53            {
 354                return candidate;
 55            }
 56
 157            suffix++;
 58        }
 59    }
 60
 61    internal static string EscapeYaml(string value)
 62    {
 2563        if (string.IsNullOrEmpty(value))
 164            return value;
 65
 2466        return value
 2467            .Replace("\\", "\\\\")
 2468            .Replace("\"", "\\\"")
 2469            .Replace("\n", "\\n")
 2470            .Replace("\r", "");
 71    }
 72}