< Summary

Information
Class: Chronicis.Api.Services.ExportService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExportService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 253
Coverable lines: 253
Total lines: 494
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 86
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%
ExportWorldToMarkdownAsync()0%342180%
ExportArticleHierarchy()0%2040%
BuildArticleMarkdown(...)0%210140%
BuildCampaignMarkdown(...)0%620%
BuildArcMarkdown(...)0%620%
HtmlToMarkdown(...)0%620%
ConvertListsToMarkdown(...)0%620%
ProcessList(...)0%272160%
AddFileToArchive()100%210%
SanitizeFileName(...)0%7280%
EscapeYaml(...)0%620%

File(s)

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

#LineLine coverage
 1using System.IO.Compression;
 2using System.Text;
 3using System.Text.RegularExpressions;
 4using Chronicis.Api.Data;
 5using Chronicis.Shared.Enums;
 6using Chronicis.Shared.Models;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace Chronicis.Api.Services;
 10
 11/// <summary>
 12/// Service for exporting world data to downloadable archives
 13/// </summary>
 14public class ExportService : IExportService
 15{
 16    private readonly ChronicisDbContext _db;
 17    private readonly IWorldMembershipService _membershipService;
 18    private readonly ILogger<ExportService> _logger;
 19
 020    public ExportService(
 021        ChronicisDbContext db,
 022        IWorldMembershipService membershipService,
 023        ILogger<ExportService> logger)
 24    {
 025        _db = db;
 026        _membershipService = membershipService;
 027        _logger = logger;
 028    }
 29
 30    public async Task<byte[]?> ExportWorldToMarkdownAsync(Guid worldId, Guid userId)
 31    {
 32        // Verify user has access to the world
 033        if (!await _membershipService.UserHasAccessAsync(worldId, userId))
 34        {
 035            _logger.LogWarning("User {UserId} attempted to export world {WorldId} without access", userId, worldId);
 036            return null;
 37        }
 38
 039        var world = await _db.Worlds
 040            .AsNoTracking()
 041            .FirstOrDefaultAsync(w => w.Id == worldId);
 42
 043        if (world == null)
 44        {
 045            _logger.LogWarning("World {WorldId} not found for export", worldId);
 046            return null;
 47        }
 48
 049        _logger.LogDebug("Starting export of world {WorldId} ({WorldName}) for user {UserId}",
 050            worldId, world.Name, userId);
 51
 52        // Get all articles for this world with their hierarchy info
 053        var articles = await _db.Articles
 054            .AsNoTracking()
 055            .Where(a => a.WorldId == worldId)
 056            .OrderBy(a => a.Title)
 057            .ToListAsync();
 58
 59        // Get campaigns and arcs for building folder structure
 060        var campaigns = await _db.Campaigns
 061            .AsNoTracking()
 062            .Where(c => c.WorldId == worldId)
 063            .ToListAsync();
 64
 065        var arcs = await _db.Arcs
 066            .AsNoTracking()
 067            .Where(a => campaigns.Select(c => c.Id).Contains(a.CampaignId))
 068            .ToListAsync();
 69
 70        // Build the zip archive
 071        using var memoryStream = new MemoryStream();
 072        using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
 73        {
 074            var worldFolderName = SanitizeFileName(world.Name);
 75
 76            // Export wiki articles (hierarchical)
 077            var wikiArticles = articles.Where(a => a.Type == ArticleType.WikiArticle || a.Type == ArticleType.Legacy).To
 078            await ExportArticleHierarchy(archive, wikiArticles, $"{worldFolderName}/Wiki", null);
 79
 80            // Export characters
 081            var characters = articles.Where(a => a.Type == ArticleType.Character).ToList();
 082            await ExportArticleHierarchy(archive, characters, $"{worldFolderName}/Characters", null);
 83
 84            // Export campaigns with their sessions
 085            foreach (var campaign in campaigns)
 86            {
 087                var campaignFolder = $"{worldFolderName}/Campaigns/{SanitizeFileName(campaign.Name)}";
 88
 89                // Campaign info file
 090                var campaignContent = BuildCampaignMarkdown(campaign);
 091                await AddFileToArchive(archive, $"{campaignFolder}/{SanitizeFileName(campaign.Name)}.md", campaignConten
 92
 93                // Export arcs and sessions
 094                var campaignArcs = arcs.Where(a => a.CampaignId == campaign.Id).OrderBy(a => a.SortOrder).ToList();
 095                foreach (var arc in campaignArcs)
 96                {
 097                    var arcFolder = $"{campaignFolder}/{SanitizeFileName(arc.Name)}";
 98
 99                    // Arc info file
 0100                    var arcContent = BuildArcMarkdown(arc);
 0101                    await AddFileToArchive(archive, $"{arcFolder}/{SanitizeFileName(arc.Name)}.md", arcContent);
 102
 103                    // Sessions in this arc
 0104                    var sessions = articles
 0105                        .Where(a => a.ArcId == arc.Id && a.Type == ArticleType.Session)
 0106                        .OrderBy(a => a.SessionDate ?? a.EffectiveDate)
 0107                        .ToList();
 108
 0109                    foreach (var session in sessions)
 110                    {
 0111                        var sessionContent = BuildArticleMarkdown(session);
 0112                        var sessionFileName = SanitizeFileName(session.Title);
 0113                        await AddFileToArchive(archive, $"{arcFolder}/{sessionFileName}/{sessionFileName}.md", sessionCo
 114
 115                        // Session notes as children
 0116                        var sessionNotes = articles.Where(a => a.ParentId == session.Id).ToList();
 0117                        foreach (var note in sessionNotes)
 118                        {
 0119                            var noteContent = BuildArticleMarkdown(note);
 0120                            var noteFileName = SanitizeFileName(note.Title);
 0121                            await AddFileToArchive(archive, $"{arcFolder}/{sessionFileName}/{noteFileName}.md", noteCont
 122                        }
 0123                    }
 0124                }
 0125            }
 0126        }
 127
 0128        memoryStream.Position = 0;
 0129        var result = memoryStream.ToArray();
 130
 0131        _logger.LogDebug("Export completed for world {WorldId}. Archive size: {Size} bytes",
 0132            worldId, result.Length);
 133
 0134        return result;
 0135    }
 136
 137    private async Task ExportArticleHierarchy(
 138        ZipArchive archive,
 139        List<Article> articles,
 140        string basePath,
 141        Guid? parentId)
 142    {
 0143        var children = articles.Where(a => a.ParentId == parentId).OrderBy(a => a.Title).ToList();
 144
 0145        foreach (var article in children)
 146        {
 0147            var articleFileName = SanitizeFileName(article.Title);
 0148            var hasChildren = articles.Any(a => a.ParentId == article.Id);
 149
 0150            if (hasChildren)
 151            {
 152                // Article with children: ArticleName/ArticleName.md
 0153                var articleFolder = $"{basePath}/{articleFileName}";
 0154                var content = BuildArticleMarkdown(article);
 0155                await AddFileToArchive(archive, $"{articleFolder}/{articleFileName}.md", content);
 156
 157                // Recursively export children
 0158                await ExportArticleHierarchy(archive, articles, articleFolder, article.Id);
 0159            }
 160            else
 161            {
 162                // Leaf article: ArticleName.md
 0163                var content = BuildArticleMarkdown(article);
 0164                await AddFileToArchive(archive, $"{basePath}/{articleFileName}.md", content);
 165            }
 0166        }
 0167    }
 168
 169    private string BuildArticleMarkdown(Article article)
 170    {
 0171        var sb = new StringBuilder();
 172
 173        // YAML frontmatter
 0174        sb.AppendLine("---");
 0175        sb.AppendLine($"title: \"{EscapeYaml(article.Title)}\"");
 0176        sb.AppendLine($"type: {article.Type}");
 0177        sb.AppendLine($"visibility: {article.Visibility}");
 0178        sb.AppendLine($"created: {article.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 0179        if (article.ModifiedAt.HasValue)
 0180            sb.AppendLine($"modified: {article.ModifiedAt:yyyy-MM-dd HH:mm:ss}");
 0181        if (article.SessionDate.HasValue)
 0182            sb.AppendLine($"session_date: {article.SessionDate:yyyy-MM-dd}");
 0183        if (!string.IsNullOrEmpty(article.InGameDate))
 0184            sb.AppendLine($"in_game_date: \"{EscapeYaml(article.InGameDate)}\"");
 0185        if (!string.IsNullOrEmpty(article.IconEmoji))
 0186            sb.AppendLine($"icon: \"{article.IconEmoji}\"");
 0187        sb.AppendLine("---");
 0188        sb.AppendLine();
 189
 190        // Title
 0191        sb.AppendLine($"# {article.Title}");
 0192        sb.AppendLine();
 193
 194        // Body content (convert HTML to Markdown)
 0195        if (!string.IsNullOrEmpty(article.Body))
 196        {
 0197            var markdown = HtmlToMarkdown(article.Body);
 0198            sb.AppendLine(markdown);
 199        }
 200
 201        // AI Summary section
 0202        if (!string.IsNullOrEmpty(article.AISummary))
 203        {
 0204            sb.AppendLine();
 0205            sb.AppendLine("---");
 0206            sb.AppendLine();
 0207            sb.AppendLine("## AI Summary");
 0208            sb.AppendLine();
 0209            sb.AppendLine(article.AISummary);
 0210            if (article.AISummaryGeneratedAt.HasValue)
 211            {
 0212                sb.AppendLine();
 0213                sb.AppendLine($"*Generated: {article.AISummaryGeneratedAt:yyyy-MM-dd HH:mm:ss}*");
 214            }
 215        }
 216
 0217        return sb.ToString();
 218    }
 219
 220    private string BuildCampaignMarkdown(Campaign campaign)
 221    {
 0222        var sb = new StringBuilder();
 223
 0224        sb.AppendLine("---");
 0225        sb.AppendLine($"title: \"{EscapeYaml(campaign.Name)}\"");
 0226        sb.AppendLine("type: Campaign");
 0227        sb.AppendLine($"created: {campaign.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 0228        sb.AppendLine("---");
 0229        sb.AppendLine();
 0230        sb.AppendLine($"# {campaign.Name}");
 0231        sb.AppendLine();
 232
 0233        if (!string.IsNullOrEmpty(campaign.Description))
 234        {
 0235            sb.AppendLine(campaign.Description);
 236        }
 237
 0238        return sb.ToString();
 239    }
 240
 241    private string BuildArcMarkdown(Arc arc)
 242    {
 0243        var sb = new StringBuilder();
 244
 0245        sb.AppendLine("---");
 0246        sb.AppendLine($"title: \"{EscapeYaml(arc.Name)}\"");
 0247        sb.AppendLine("type: Arc");
 0248        sb.AppendLine($"sort_order: {arc.SortOrder}");
 0249        sb.AppendLine($"created: {arc.CreatedAt:yyyy-MM-dd HH:mm:ss}");
 0250        sb.AppendLine("---");
 0251        sb.AppendLine();
 0252        sb.AppendLine($"# {arc.Name}");
 0253        sb.AppendLine();
 254
 0255        if (!string.IsNullOrEmpty(arc.Description))
 256        {
 0257            sb.AppendLine(arc.Description);
 258        }
 259
 0260        return sb.ToString();
 261    }
 262
 263    /// <summary>
 264    /// Convert HTML (from TipTap) to Markdown for export
 265    /// </summary>
 266    private string HtmlToMarkdown(string html)
 267    {
 0268        if (string.IsNullOrWhiteSpace(html))
 0269            return string.Empty;
 270
 0271        var markdown = html;
 272
 273        // Wiki links: <span data-type="wiki-link" data-target-id="guid" data-display="display">text</span>
 274        // Convert to: [[display]] (we lose the GUID link, but preserve the text)
 0275        markdown = Regex.Replace(markdown,
 0276            @"<span[^>]*data-type=""wiki-link""[^>]*data-display=""([^""]+)""[^>]*>.*?</span>",
 0277            "[[$1]]", RegexOptions.IgnoreCase);
 0278        markdown = Regex.Replace(markdown,
 0279            @"<span[^>]*data-type=""wiki-link""[^>]*>([^<]+)</span>",
 0280            "[[$1]]", RegexOptions.IgnoreCase);
 281
 282        // Headers
 0283        markdown = Regex.Replace(markdown, @"<h1[^>]*>(.*?)</h1>", "# $1\n\n", RegexOptions.IgnoreCase | RegexOptions.Si
 0284        markdown = Regex.Replace(markdown, @"<h2[^>]*>(.*?)</h2>", "## $1\n\n", RegexOptions.IgnoreCase | RegexOptions.S
 0285        markdown = Regex.Replace(markdown, @"<h3[^>]*>(.*?)</h3>", "### $1\n\n", RegexOptions.IgnoreCase | RegexOptions.
 0286        markdown = Regex.Replace(markdown, @"<h4[^>]*>(.*?)</h4>", "#### $1\n\n", RegexOptions.IgnoreCase | RegexOptions
 0287        markdown = Regex.Replace(markdown, @"<h5[^>]*>(.*?)</h5>", "##### $1\n\n", RegexOptions.IgnoreCase | RegexOption
 0288        markdown = Regex.Replace(markdown, @"<h6[^>]*>(.*?)</h6>", "###### $1\n\n", RegexOptions.IgnoreCase | RegexOptio
 289
 290        // Bold and italic
 0291        markdown = Regex.Replace(markdown, @"<strong[^>]*>(.*?)</strong>", "**$1**", RegexOptions.IgnoreCase | RegexOpti
 0292        markdown = Regex.Replace(markdown, @"<b[^>]*>(.*?)</b>", "**$1**", RegexOptions.IgnoreCase | RegexOptions.Single
 0293        markdown = Regex.Replace(markdown, @"<em[^>]*>(.*?)</em>", "*$1*", RegexOptions.IgnoreCase | RegexOptions.Single
 0294        markdown = Regex.Replace(markdown, @"<i[^>]*>(.*?)</i>", "*$1*", RegexOptions.IgnoreCase | RegexOptions.Singleli
 295
 296        // Links
 0297        markdown = Regex.Replace(markdown, @"<a[^>]*href=""([^""]*)""[^>]*>(.*?)</a>", "[$2]($1)", RegexOptions.IgnoreCa
 298
 299        // Code blocks
 0300        markdown = Regex.Replace(markdown, @"<pre[^>]*><code[^>]*>([\s\S]*?)</code></pre>", "```\n$1\n```\n\n", RegexOpt
 301
 302        // Inline code
 0303        markdown = Regex.Replace(markdown, @"<code[^>]*>(.*?)</code>", "`$1`", RegexOptions.IgnoreCase | RegexOptions.Si
 304
 305        // Blockquotes
 0306        markdown = Regex.Replace(markdown, @"<blockquote[^>]*>([\s\S]*?)</blockquote>", m =>
 0307        {
 0308            var content = m.Groups[1].Value;
 0309            content = Regex.Replace(content, @"<p[^>]*>(.*?)</p>", "$1", RegexOptions.IgnoreCase | RegexOptions.Singleli
 0310            var lines = content.Split('\n').Select(l => "> " + l.Trim()).Where(l => l != "> ");
 0311            return string.Join("\n", lines) + "\n\n";
 0312        }, RegexOptions.IgnoreCase);
 313
 314        // Handle nested lists recursively
 0315        markdown = ConvertListsToMarkdown(markdown);
 316
 317        // Paragraphs
 0318        markdown = Regex.Replace(markdown, @"<p[^>]*>(.*?)</p>", "$1\n\n", RegexOptions.IgnoreCase | RegexOptions.Single
 319
 320        // Line breaks
 0321        markdown = Regex.Replace(markdown, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
 322
 323        // Horizontal rules
 0324        markdown = Regex.Replace(markdown, @"<hr[^>]*/?>", "\n---\n\n", RegexOptions.IgnoreCase);
 325
 326        // Remove any remaining HTML tags
 0327        markdown = Regex.Replace(markdown, @"<[^>]+>", "");
 328
 329        // Decode HTML entities
 0330        markdown = System.Net.WebUtility.HtmlDecode(markdown);
 331
 332        // Clean up multiple newlines
 0333        markdown = Regex.Replace(markdown, @"\n{3,}", "\n\n");
 0334        markdown = markdown.Trim();
 335
 0336        return markdown;
 337    }
 338
 339    /// <summary>
 340    /// Convert HTML lists (including nested) to Markdown
 341    /// </summary>
 342    private string ConvertListsToMarkdown(string html)
 343    {
 344        // Process lists from innermost to outermost
 0345        var result = html;
 0346        var previousResult = "";
 347
 348        // Keep processing until no more changes (handles deep nesting)
 0349        while (result != previousResult)
 350        {
 0351            previousResult = result;
 352
 353            // Unordered lists
 0354            result = Regex.Replace(result, @"<ul[^>]*>([\s\S]*?)</ul>", m =>
 0355            {
 0356                return ProcessList(m.Groups[1].Value, false, 0);
 0357            }, RegexOptions.IgnoreCase);
 358
 359            // Ordered lists
 0360            result = Regex.Replace(result, @"<ol[^>]*>([\s\S]*?)</ol>", m =>
 0361            {
 0362                return ProcessList(m.Groups[1].Value, true, 0);
 0363            }, RegexOptions.IgnoreCase);
 364        }
 365
 0366        return result;
 367    }
 368
 369    private string ProcessList(string listContent, bool ordered, int indentLevel)
 370    {
 0371        var sb = new StringBuilder();
 0372        var indent = new string(' ', indentLevel * 2);
 0373        var counter = 1;
 374
 375        // Match list items, being careful with nested content
 0376        var liPattern = @"<li[^>]*>([\s\S]*?)</li>";
 0377        var matches = Regex.Matches(listContent, liPattern, RegexOptions.IgnoreCase);
 378
 0379        foreach (Match match in matches)
 380        {
 0381            var itemContent = match.Groups[1].Value;
 382
 383            // Check for nested lists
 0384            var hasNestedUl = Regex.IsMatch(itemContent, @"<ul[^>]*>", RegexOptions.IgnoreCase);
 0385            var hasNestedOl = Regex.IsMatch(itemContent, @"<ol[^>]*>", RegexOptions.IgnoreCase);
 386
 387            // Extract text before any nested list
 388            string textContent;
 0389            string nestedListContent = "";
 390
 0391            if (hasNestedUl || hasNestedOl)
 392            {
 0393                var nestedListMatch = Regex.Match(itemContent, @"(<[uo]l[^>]*>[\s\S]*</[uo]l>)", RegexOptions.IgnoreCase
 0394                if (nestedListMatch.Success)
 395                {
 0396                    var nestedListStart = nestedListMatch.Index;
 0397                    textContent = itemContent.Substring(0, nestedListStart);
 0398                    nestedListContent = nestedListMatch.Groups[1].Value;
 399                }
 400                else
 401                {
 0402                    textContent = itemContent;
 403                }
 404            }
 405            else
 406            {
 0407                textContent = itemContent;
 408            }
 409
 410            // Clean up text content (remove p tags, etc.)
 0411            textContent = Regex.Replace(textContent, @"<p[^>]*>(.*?)</p>", "$1", RegexOptions.IgnoreCase | RegexOptions.
 0412            textContent = Regex.Replace(textContent, @"<[^>]+>", "").Trim();
 413
 414            // Write the list item
 0415            var prefix = ordered ? $"{counter}. " : "- ";
 0416            sb.AppendLine($"{indent}{prefix}{textContent}");
 0417            counter++;
 418
 419            // Process nested list with increased indent
 0420            if (!string.IsNullOrEmpty(nestedListContent))
 421            {
 422                // Process nested unordered list
 0423                var nestedUlMatch = Regex.Match(nestedListContent, @"<ul[^>]*>([\s\S]*?)</ul>", RegexOptions.IgnoreCase)
 0424                if (nestedUlMatch.Success)
 425                {
 0426                    var nestedResult = ProcessList(nestedUlMatch.Groups[1].Value, false, indentLevel + 1);
 0427                    sb.Append(nestedResult);
 428                }
 429
 430                // Process nested ordered list
 0431                var nestedOlMatch = Regex.Match(nestedListContent, @"<ol[^>]*>([\s\S]*?)</ol>", RegexOptions.IgnoreCase)
 0432                if (nestedOlMatch.Success)
 433                {
 0434                    var nestedResult = ProcessList(nestedOlMatch.Groups[1].Value, true, indentLevel + 1);
 0435                    sb.Append(nestedResult);
 436                }
 437            }
 438        }
 439
 0440        if (indentLevel == 0)
 441        {
 0442            sb.AppendLine();
 443        }
 444
 0445        return sb.ToString();
 446    }
 447
 448    private static async Task AddFileToArchive(ZipArchive archive, string path, string content)
 449    {
 0450        var entry = archive.CreateEntry(path, CompressionLevel.Optimal);
 0451        using var stream = entry.Open();
 0452        using var writer = new StreamWriter(stream, Encoding.UTF8);
 0453        await writer.WriteAsync(content);
 0454    }
 455
 456    private static string SanitizeFileName(string name)
 457    {
 0458        if (string.IsNullOrWhiteSpace(name))
 0459            return "Untitled";
 460
 461        // Replace invalid filename characters
 0462        var invalid = Path.GetInvalidFileNameChars();
 0463        var sanitized = new StringBuilder(name);
 0464        foreach (var c in invalid)
 465        {
 0466            sanitized.Replace(c, '_');
 467        }
 468
 469        // Also replace some characters that might cause issues
 0470        sanitized.Replace('/', '_');
 0471        sanitized.Replace('\\', '_');
 0472        sanitized.Replace(':', '_');
 473
 0474        var result = sanitized.ToString().Trim();
 475
 476        // Limit length
 0477        if (result.Length > 100)
 0478            result = result.Substring(0, 100);
 479
 0480        return string.IsNullOrWhiteSpace(result) ? "Untitled" : result;
 481    }
 482
 483    private static string EscapeYaml(string value)
 484    {
 0485        if (string.IsNullOrEmpty(value))
 0486            return value;
 487
 0488        return value
 0489            .Replace("\\", "\\\\")
 0490            .Replace("\"", "\\\"")
 0491            .Replace("\n", "\\n")
 0492            .Replace("\r", "");
 493    }
 494}