| | | 1 | | using System.IO.Compression; |
| | | 2 | | using System.Text; |
| | | 3 | | using System.Text.RegularExpressions; |
| | | 4 | | using Chronicis.Api.Data; |
| | | 5 | | using Chronicis.Shared.Enums; |
| | | 6 | | using Chronicis.Shared.Models; |
| | | 7 | | using Microsoft.EntityFrameworkCore; |
| | | 8 | | |
| | | 9 | | namespace Chronicis.Api.Services; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Service for exporting world data to downloadable archives |
| | | 13 | | /// </summary> |
| | | 14 | | public class ExportService : IExportService |
| | | 15 | | { |
| | | 16 | | private readonly ChronicisDbContext _db; |
| | | 17 | | private readonly IWorldMembershipService _membershipService; |
| | | 18 | | private readonly ILogger<ExportService> _logger; |
| | | 19 | | |
| | 0 | 20 | | public ExportService( |
| | 0 | 21 | | ChronicisDbContext db, |
| | 0 | 22 | | IWorldMembershipService membershipService, |
| | 0 | 23 | | ILogger<ExportService> logger) |
| | | 24 | | { |
| | 0 | 25 | | _db = db; |
| | 0 | 26 | | _membershipService = membershipService; |
| | 0 | 27 | | _logger = logger; |
| | 0 | 28 | | } |
| | | 29 | | |
| | | 30 | | public async Task<byte[]?> ExportWorldToMarkdownAsync(Guid worldId, Guid userId) |
| | | 31 | | { |
| | | 32 | | // Verify user has access to the world |
| | 0 | 33 | | if (!await _membershipService.UserHasAccessAsync(worldId, userId)) |
| | | 34 | | { |
| | 0 | 35 | | _logger.LogWarning("User {UserId} attempted to export world {WorldId} without access", userId, worldId); |
| | 0 | 36 | | return null; |
| | | 37 | | } |
| | | 38 | | |
| | 0 | 39 | | var world = await _db.Worlds |
| | 0 | 40 | | .AsNoTracking() |
| | 0 | 41 | | .FirstOrDefaultAsync(w => w.Id == worldId); |
| | | 42 | | |
| | 0 | 43 | | if (world == null) |
| | | 44 | | { |
| | 0 | 45 | | _logger.LogWarning("World {WorldId} not found for export", worldId); |
| | 0 | 46 | | return null; |
| | | 47 | | } |
| | | 48 | | |
| | 0 | 49 | | _logger.LogDebug("Starting export of world {WorldId} ({WorldName}) for user {UserId}", |
| | 0 | 50 | | worldId, world.Name, userId); |
| | | 51 | | |
| | | 52 | | // Get all articles for this world with their hierarchy info |
| | 0 | 53 | | var articles = await _db.Articles |
| | 0 | 54 | | .AsNoTracking() |
| | 0 | 55 | | .Where(a => a.WorldId == worldId) |
| | 0 | 56 | | .OrderBy(a => a.Title) |
| | 0 | 57 | | .ToListAsync(); |
| | | 58 | | |
| | | 59 | | // Get campaigns and arcs for building folder structure |
| | 0 | 60 | | var campaigns = await _db.Campaigns |
| | 0 | 61 | | .AsNoTracking() |
| | 0 | 62 | | .Where(c => c.WorldId == worldId) |
| | 0 | 63 | | .ToListAsync(); |
| | | 64 | | |
| | 0 | 65 | | var arcs = await _db.Arcs |
| | 0 | 66 | | .AsNoTracking() |
| | 0 | 67 | | .Where(a => campaigns.Select(c => c.Id).Contains(a.CampaignId)) |
| | 0 | 68 | | .ToListAsync(); |
| | | 69 | | |
| | | 70 | | // Build the zip archive |
| | 0 | 71 | | using var memoryStream = new MemoryStream(); |
| | 0 | 72 | | using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) |
| | | 73 | | { |
| | 0 | 74 | | var worldFolderName = SanitizeFileName(world.Name); |
| | | 75 | | |
| | | 76 | | // Export wiki articles (hierarchical) |
| | 0 | 77 | | var wikiArticles = articles.Where(a => a.Type == ArticleType.WikiArticle || a.Type == ArticleType.Legacy).To |
| | 0 | 78 | | await ExportArticleHierarchy(archive, wikiArticles, $"{worldFolderName}/Wiki", null); |
| | | 79 | | |
| | | 80 | | // Export characters |
| | 0 | 81 | | var characters = articles.Where(a => a.Type == ArticleType.Character).ToList(); |
| | 0 | 82 | | await ExportArticleHierarchy(archive, characters, $"{worldFolderName}/Characters", null); |
| | | 83 | | |
| | | 84 | | // Export campaigns with their sessions |
| | 0 | 85 | | foreach (var campaign in campaigns) |
| | | 86 | | { |
| | 0 | 87 | | var campaignFolder = $"{worldFolderName}/Campaigns/{SanitizeFileName(campaign.Name)}"; |
| | | 88 | | |
| | | 89 | | // Campaign info file |
| | 0 | 90 | | var campaignContent = BuildCampaignMarkdown(campaign); |
| | 0 | 91 | | await AddFileToArchive(archive, $"{campaignFolder}/{SanitizeFileName(campaign.Name)}.md", campaignConten |
| | | 92 | | |
| | | 93 | | // Export arcs and sessions |
| | 0 | 94 | | var campaignArcs = arcs.Where(a => a.CampaignId == campaign.Id).OrderBy(a => a.SortOrder).ToList(); |
| | 0 | 95 | | foreach (var arc in campaignArcs) |
| | | 96 | | { |
| | 0 | 97 | | var arcFolder = $"{campaignFolder}/{SanitizeFileName(arc.Name)}"; |
| | | 98 | | |
| | | 99 | | // Arc info file |
| | 0 | 100 | | var arcContent = BuildArcMarkdown(arc); |
| | 0 | 101 | | await AddFileToArchive(archive, $"{arcFolder}/{SanitizeFileName(arc.Name)}.md", arcContent); |
| | | 102 | | |
| | | 103 | | // Sessions in this arc |
| | 0 | 104 | | var sessions = articles |
| | 0 | 105 | | .Where(a => a.ArcId == arc.Id && a.Type == ArticleType.Session) |
| | 0 | 106 | | .OrderBy(a => a.SessionDate ?? a.EffectiveDate) |
| | 0 | 107 | | .ToList(); |
| | | 108 | | |
| | 0 | 109 | | foreach (var session in sessions) |
| | | 110 | | { |
| | 0 | 111 | | var sessionContent = BuildArticleMarkdown(session); |
| | 0 | 112 | | var sessionFileName = SanitizeFileName(session.Title); |
| | 0 | 113 | | await AddFileToArchive(archive, $"{arcFolder}/{sessionFileName}/{sessionFileName}.md", sessionCo |
| | | 114 | | |
| | | 115 | | // Session notes as children |
| | 0 | 116 | | var sessionNotes = articles.Where(a => a.ParentId == session.Id).ToList(); |
| | 0 | 117 | | foreach (var note in sessionNotes) |
| | | 118 | | { |
| | 0 | 119 | | var noteContent = BuildArticleMarkdown(note); |
| | 0 | 120 | | var noteFileName = SanitizeFileName(note.Title); |
| | 0 | 121 | | await AddFileToArchive(archive, $"{arcFolder}/{sessionFileName}/{noteFileName}.md", noteCont |
| | | 122 | | } |
| | 0 | 123 | | } |
| | 0 | 124 | | } |
| | 0 | 125 | | } |
| | 0 | 126 | | } |
| | | 127 | | |
| | 0 | 128 | | memoryStream.Position = 0; |
| | 0 | 129 | | var result = memoryStream.ToArray(); |
| | | 130 | | |
| | 0 | 131 | | _logger.LogDebug("Export completed for world {WorldId}. Archive size: {Size} bytes", |
| | 0 | 132 | | worldId, result.Length); |
| | | 133 | | |
| | 0 | 134 | | return result; |
| | 0 | 135 | | } |
| | | 136 | | |
| | | 137 | | private async Task ExportArticleHierarchy( |
| | | 138 | | ZipArchive archive, |
| | | 139 | | List<Article> articles, |
| | | 140 | | string basePath, |
| | | 141 | | Guid? parentId) |
| | | 142 | | { |
| | 0 | 143 | | var children = articles.Where(a => a.ParentId == parentId).OrderBy(a => a.Title).ToList(); |
| | | 144 | | |
| | 0 | 145 | | foreach (var article in children) |
| | | 146 | | { |
| | 0 | 147 | | var articleFileName = SanitizeFileName(article.Title); |
| | 0 | 148 | | var hasChildren = articles.Any(a => a.ParentId == article.Id); |
| | | 149 | | |
| | 0 | 150 | | if (hasChildren) |
| | | 151 | | { |
| | | 152 | | // Article with children: ArticleName/ArticleName.md |
| | 0 | 153 | | var articleFolder = $"{basePath}/{articleFileName}"; |
| | 0 | 154 | | var content = BuildArticleMarkdown(article); |
| | 0 | 155 | | await AddFileToArchive(archive, $"{articleFolder}/{articleFileName}.md", content); |
| | | 156 | | |
| | | 157 | | // Recursively export children |
| | 0 | 158 | | await ExportArticleHierarchy(archive, articles, articleFolder, article.Id); |
| | 0 | 159 | | } |
| | | 160 | | else |
| | | 161 | | { |
| | | 162 | | // Leaf article: ArticleName.md |
| | 0 | 163 | | var content = BuildArticleMarkdown(article); |
| | 0 | 164 | | await AddFileToArchive(archive, $"{basePath}/{articleFileName}.md", content); |
| | | 165 | | } |
| | 0 | 166 | | } |
| | 0 | 167 | | } |
| | | 168 | | |
| | | 169 | | private string BuildArticleMarkdown(Article article) |
| | | 170 | | { |
| | 0 | 171 | | var sb = new StringBuilder(); |
| | | 172 | | |
| | | 173 | | // YAML frontmatter |
| | 0 | 174 | | sb.AppendLine("---"); |
| | 0 | 175 | | sb.AppendLine($"title: \"{EscapeYaml(article.Title)}\""); |
| | 0 | 176 | | sb.AppendLine($"type: {article.Type}"); |
| | 0 | 177 | | sb.AppendLine($"visibility: {article.Visibility}"); |
| | 0 | 178 | | sb.AppendLine($"created: {article.CreatedAt:yyyy-MM-dd HH:mm:ss}"); |
| | 0 | 179 | | if (article.ModifiedAt.HasValue) |
| | 0 | 180 | | sb.AppendLine($"modified: {article.ModifiedAt:yyyy-MM-dd HH:mm:ss}"); |
| | 0 | 181 | | if (article.SessionDate.HasValue) |
| | 0 | 182 | | sb.AppendLine($"session_date: {article.SessionDate:yyyy-MM-dd}"); |
| | 0 | 183 | | if (!string.IsNullOrEmpty(article.InGameDate)) |
| | 0 | 184 | | sb.AppendLine($"in_game_date: \"{EscapeYaml(article.InGameDate)}\""); |
| | 0 | 185 | | if (!string.IsNullOrEmpty(article.IconEmoji)) |
| | 0 | 186 | | sb.AppendLine($"icon: \"{article.IconEmoji}\""); |
| | 0 | 187 | | sb.AppendLine("---"); |
| | 0 | 188 | | sb.AppendLine(); |
| | | 189 | | |
| | | 190 | | // Title |
| | 0 | 191 | | sb.AppendLine($"# {article.Title}"); |
| | 0 | 192 | | sb.AppendLine(); |
| | | 193 | | |
| | | 194 | | // Body content (convert HTML to Markdown) |
| | 0 | 195 | | if (!string.IsNullOrEmpty(article.Body)) |
| | | 196 | | { |
| | 0 | 197 | | var markdown = HtmlToMarkdown(article.Body); |
| | 0 | 198 | | sb.AppendLine(markdown); |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | // AI Summary section |
| | 0 | 202 | | if (!string.IsNullOrEmpty(article.AISummary)) |
| | | 203 | | { |
| | 0 | 204 | | sb.AppendLine(); |
| | 0 | 205 | | sb.AppendLine("---"); |
| | 0 | 206 | | sb.AppendLine(); |
| | 0 | 207 | | sb.AppendLine("## AI Summary"); |
| | 0 | 208 | | sb.AppendLine(); |
| | 0 | 209 | | sb.AppendLine(article.AISummary); |
| | 0 | 210 | | if (article.AISummaryGeneratedAt.HasValue) |
| | | 211 | | { |
| | 0 | 212 | | sb.AppendLine(); |
| | 0 | 213 | | sb.AppendLine($"*Generated: {article.AISummaryGeneratedAt:yyyy-MM-dd HH:mm:ss}*"); |
| | | 214 | | } |
| | | 215 | | } |
| | | 216 | | |
| | 0 | 217 | | return sb.ToString(); |
| | | 218 | | } |
| | | 219 | | |
| | | 220 | | private string BuildCampaignMarkdown(Campaign campaign) |
| | | 221 | | { |
| | 0 | 222 | | var sb = new StringBuilder(); |
| | | 223 | | |
| | 0 | 224 | | sb.AppendLine("---"); |
| | 0 | 225 | | sb.AppendLine($"title: \"{EscapeYaml(campaign.Name)}\""); |
| | 0 | 226 | | sb.AppendLine("type: Campaign"); |
| | 0 | 227 | | sb.AppendLine($"created: {campaign.CreatedAt:yyyy-MM-dd HH:mm:ss}"); |
| | 0 | 228 | | sb.AppendLine("---"); |
| | 0 | 229 | | sb.AppendLine(); |
| | 0 | 230 | | sb.AppendLine($"# {campaign.Name}"); |
| | 0 | 231 | | sb.AppendLine(); |
| | | 232 | | |
| | 0 | 233 | | if (!string.IsNullOrEmpty(campaign.Description)) |
| | | 234 | | { |
| | 0 | 235 | | sb.AppendLine(campaign.Description); |
| | | 236 | | } |
| | | 237 | | |
| | 0 | 238 | | return sb.ToString(); |
| | | 239 | | } |
| | | 240 | | |
| | | 241 | | private string BuildArcMarkdown(Arc arc) |
| | | 242 | | { |
| | 0 | 243 | | var sb = new StringBuilder(); |
| | | 244 | | |
| | 0 | 245 | | sb.AppendLine("---"); |
| | 0 | 246 | | sb.AppendLine($"title: \"{EscapeYaml(arc.Name)}\""); |
| | 0 | 247 | | sb.AppendLine("type: Arc"); |
| | 0 | 248 | | sb.AppendLine($"sort_order: {arc.SortOrder}"); |
| | 0 | 249 | | sb.AppendLine($"created: {arc.CreatedAt:yyyy-MM-dd HH:mm:ss}"); |
| | 0 | 250 | | sb.AppendLine("---"); |
| | 0 | 251 | | sb.AppendLine(); |
| | 0 | 252 | | sb.AppendLine($"# {arc.Name}"); |
| | 0 | 253 | | sb.AppendLine(); |
| | | 254 | | |
| | 0 | 255 | | if (!string.IsNullOrEmpty(arc.Description)) |
| | | 256 | | { |
| | 0 | 257 | | sb.AppendLine(arc.Description); |
| | | 258 | | } |
| | | 259 | | |
| | 0 | 260 | | 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 | | { |
| | 0 | 268 | | if (string.IsNullOrWhiteSpace(html)) |
| | 0 | 269 | | return string.Empty; |
| | | 270 | | |
| | 0 | 271 | | 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) |
| | 0 | 275 | | markdown = Regex.Replace(markdown, |
| | 0 | 276 | | @"<span[^>]*data-type=""wiki-link""[^>]*data-display=""([^""]+)""[^>]*>.*?</span>", |
| | 0 | 277 | | "[[$1]]", RegexOptions.IgnoreCase); |
| | 0 | 278 | | markdown = Regex.Replace(markdown, |
| | 0 | 279 | | @"<span[^>]*data-type=""wiki-link""[^>]*>([^<]+)</span>", |
| | 0 | 280 | | "[[$1]]", RegexOptions.IgnoreCase); |
| | | 281 | | |
| | | 282 | | // Headers |
| | 0 | 283 | | markdown = Regex.Replace(markdown, @"<h1[^>]*>(.*?)</h1>", "# $1\n\n", RegexOptions.IgnoreCase | RegexOptions.Si |
| | 0 | 284 | | markdown = Regex.Replace(markdown, @"<h2[^>]*>(.*?)</h2>", "## $1\n\n", RegexOptions.IgnoreCase | RegexOptions.S |
| | 0 | 285 | | markdown = Regex.Replace(markdown, @"<h3[^>]*>(.*?)</h3>", "### $1\n\n", RegexOptions.IgnoreCase | RegexOptions. |
| | 0 | 286 | | markdown = Regex.Replace(markdown, @"<h4[^>]*>(.*?)</h4>", "#### $1\n\n", RegexOptions.IgnoreCase | RegexOptions |
| | 0 | 287 | | markdown = Regex.Replace(markdown, @"<h5[^>]*>(.*?)</h5>", "##### $1\n\n", RegexOptions.IgnoreCase | RegexOption |
| | 0 | 288 | | markdown = Regex.Replace(markdown, @"<h6[^>]*>(.*?)</h6>", "###### $1\n\n", RegexOptions.IgnoreCase | RegexOptio |
| | | 289 | | |
| | | 290 | | // Bold and italic |
| | 0 | 291 | | markdown = Regex.Replace(markdown, @"<strong[^>]*>(.*?)</strong>", "**$1**", RegexOptions.IgnoreCase | RegexOpti |
| | 0 | 292 | | markdown = Regex.Replace(markdown, @"<b[^>]*>(.*?)</b>", "**$1**", RegexOptions.IgnoreCase | RegexOptions.Single |
| | 0 | 293 | | markdown = Regex.Replace(markdown, @"<em[^>]*>(.*?)</em>", "*$1*", RegexOptions.IgnoreCase | RegexOptions.Single |
| | 0 | 294 | | markdown = Regex.Replace(markdown, @"<i[^>]*>(.*?)</i>", "*$1*", RegexOptions.IgnoreCase | RegexOptions.Singleli |
| | | 295 | | |
| | | 296 | | // Links |
| | 0 | 297 | | markdown = Regex.Replace(markdown, @"<a[^>]*href=""([^""]*)""[^>]*>(.*?)</a>", "[$2]($1)", RegexOptions.IgnoreCa |
| | | 298 | | |
| | | 299 | | // Code blocks |
| | 0 | 300 | | markdown = Regex.Replace(markdown, @"<pre[^>]*><code[^>]*>([\s\S]*?)</code></pre>", "```\n$1\n```\n\n", RegexOpt |
| | | 301 | | |
| | | 302 | | // Inline code |
| | 0 | 303 | | markdown = Regex.Replace(markdown, @"<code[^>]*>(.*?)</code>", "`$1`", RegexOptions.IgnoreCase | RegexOptions.Si |
| | | 304 | | |
| | | 305 | | // Blockquotes |
| | 0 | 306 | | markdown = Regex.Replace(markdown, @"<blockquote[^>]*>([\s\S]*?)</blockquote>", m => |
| | 0 | 307 | | { |
| | 0 | 308 | | var content = m.Groups[1].Value; |
| | 0 | 309 | | content = Regex.Replace(content, @"<p[^>]*>(.*?)</p>", "$1", RegexOptions.IgnoreCase | RegexOptions.Singleli |
| | 0 | 310 | | var lines = content.Split('\n').Select(l => "> " + l.Trim()).Where(l => l != "> "); |
| | 0 | 311 | | return string.Join("\n", lines) + "\n\n"; |
| | 0 | 312 | | }, RegexOptions.IgnoreCase); |
| | | 313 | | |
| | | 314 | | // Handle nested lists recursively |
| | 0 | 315 | | markdown = ConvertListsToMarkdown(markdown); |
| | | 316 | | |
| | | 317 | | // Paragraphs |
| | 0 | 318 | | markdown = Regex.Replace(markdown, @"<p[^>]*>(.*?)</p>", "$1\n\n", RegexOptions.IgnoreCase | RegexOptions.Single |
| | | 319 | | |
| | | 320 | | // Line breaks |
| | 0 | 321 | | markdown = Regex.Replace(markdown, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase); |
| | | 322 | | |
| | | 323 | | // Horizontal rules |
| | 0 | 324 | | markdown = Regex.Replace(markdown, @"<hr[^>]*/?>", "\n---\n\n", RegexOptions.IgnoreCase); |
| | | 325 | | |
| | | 326 | | // Remove any remaining HTML tags |
| | 0 | 327 | | markdown = Regex.Replace(markdown, @"<[^>]+>", ""); |
| | | 328 | | |
| | | 329 | | // Decode HTML entities |
| | 0 | 330 | | markdown = System.Net.WebUtility.HtmlDecode(markdown); |
| | | 331 | | |
| | | 332 | | // Clean up multiple newlines |
| | 0 | 333 | | markdown = Regex.Replace(markdown, @"\n{3,}", "\n\n"); |
| | 0 | 334 | | markdown = markdown.Trim(); |
| | | 335 | | |
| | 0 | 336 | | 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 |
| | 0 | 345 | | var result = html; |
| | 0 | 346 | | var previousResult = ""; |
| | | 347 | | |
| | | 348 | | // Keep processing until no more changes (handles deep nesting) |
| | 0 | 349 | | while (result != previousResult) |
| | | 350 | | { |
| | 0 | 351 | | previousResult = result; |
| | | 352 | | |
| | | 353 | | // Unordered lists |
| | 0 | 354 | | result = Regex.Replace(result, @"<ul[^>]*>([\s\S]*?)</ul>", m => |
| | 0 | 355 | | { |
| | 0 | 356 | | return ProcessList(m.Groups[1].Value, false, 0); |
| | 0 | 357 | | }, RegexOptions.IgnoreCase); |
| | | 358 | | |
| | | 359 | | // Ordered lists |
| | 0 | 360 | | result = Regex.Replace(result, @"<ol[^>]*>([\s\S]*?)</ol>", m => |
| | 0 | 361 | | { |
| | 0 | 362 | | return ProcessList(m.Groups[1].Value, true, 0); |
| | 0 | 363 | | }, RegexOptions.IgnoreCase); |
| | | 364 | | } |
| | | 365 | | |
| | 0 | 366 | | return result; |
| | | 367 | | } |
| | | 368 | | |
| | | 369 | | private string ProcessList(string listContent, bool ordered, int indentLevel) |
| | | 370 | | { |
| | 0 | 371 | | var sb = new StringBuilder(); |
| | 0 | 372 | | var indent = new string(' ', indentLevel * 2); |
| | 0 | 373 | | var counter = 1; |
| | | 374 | | |
| | | 375 | | // Match list items, being careful with nested content |
| | 0 | 376 | | var liPattern = @"<li[^>]*>([\s\S]*?)</li>"; |
| | 0 | 377 | | var matches = Regex.Matches(listContent, liPattern, RegexOptions.IgnoreCase); |
| | | 378 | | |
| | 0 | 379 | | foreach (Match match in matches) |
| | | 380 | | { |
| | 0 | 381 | | var itemContent = match.Groups[1].Value; |
| | | 382 | | |
| | | 383 | | // Check for nested lists |
| | 0 | 384 | | var hasNestedUl = Regex.IsMatch(itemContent, @"<ul[^>]*>", RegexOptions.IgnoreCase); |
| | 0 | 385 | | var hasNestedOl = Regex.IsMatch(itemContent, @"<ol[^>]*>", RegexOptions.IgnoreCase); |
| | | 386 | | |
| | | 387 | | // Extract text before any nested list |
| | | 388 | | string textContent; |
| | 0 | 389 | | string nestedListContent = ""; |
| | | 390 | | |
| | 0 | 391 | | if (hasNestedUl || hasNestedOl) |
| | | 392 | | { |
| | 0 | 393 | | var nestedListMatch = Regex.Match(itemContent, @"(<[uo]l[^>]*>[\s\S]*</[uo]l>)", RegexOptions.IgnoreCase |
| | 0 | 394 | | if (nestedListMatch.Success) |
| | | 395 | | { |
| | 0 | 396 | | var nestedListStart = nestedListMatch.Index; |
| | 0 | 397 | | textContent = itemContent.Substring(0, nestedListStart); |
| | 0 | 398 | | nestedListContent = nestedListMatch.Groups[1].Value; |
| | | 399 | | } |
| | | 400 | | else |
| | | 401 | | { |
| | 0 | 402 | | textContent = itemContent; |
| | | 403 | | } |
| | | 404 | | } |
| | | 405 | | else |
| | | 406 | | { |
| | 0 | 407 | | textContent = itemContent; |
| | | 408 | | } |
| | | 409 | | |
| | | 410 | | // Clean up text content (remove p tags, etc.) |
| | 0 | 411 | | textContent = Regex.Replace(textContent, @"<p[^>]*>(.*?)</p>", "$1", RegexOptions.IgnoreCase | RegexOptions. |
| | 0 | 412 | | textContent = Regex.Replace(textContent, @"<[^>]+>", "").Trim(); |
| | | 413 | | |
| | | 414 | | // Write the list item |
| | 0 | 415 | | var prefix = ordered ? $"{counter}. " : "- "; |
| | 0 | 416 | | sb.AppendLine($"{indent}{prefix}{textContent}"); |
| | 0 | 417 | | counter++; |
| | | 418 | | |
| | | 419 | | // Process nested list with increased indent |
| | 0 | 420 | | if (!string.IsNullOrEmpty(nestedListContent)) |
| | | 421 | | { |
| | | 422 | | // Process nested unordered list |
| | 0 | 423 | | var nestedUlMatch = Regex.Match(nestedListContent, @"<ul[^>]*>([\s\S]*?)</ul>", RegexOptions.IgnoreCase) |
| | 0 | 424 | | if (nestedUlMatch.Success) |
| | | 425 | | { |
| | 0 | 426 | | var nestedResult = ProcessList(nestedUlMatch.Groups[1].Value, false, indentLevel + 1); |
| | 0 | 427 | | sb.Append(nestedResult); |
| | | 428 | | } |
| | | 429 | | |
| | | 430 | | // Process nested ordered list |
| | 0 | 431 | | var nestedOlMatch = Regex.Match(nestedListContent, @"<ol[^>]*>([\s\S]*?)</ol>", RegexOptions.IgnoreCase) |
| | 0 | 432 | | if (nestedOlMatch.Success) |
| | | 433 | | { |
| | 0 | 434 | | var nestedResult = ProcessList(nestedOlMatch.Groups[1].Value, true, indentLevel + 1); |
| | 0 | 435 | | sb.Append(nestedResult); |
| | | 436 | | } |
| | | 437 | | } |
| | | 438 | | } |
| | | 439 | | |
| | 0 | 440 | | if (indentLevel == 0) |
| | | 441 | | { |
| | 0 | 442 | | sb.AppendLine(); |
| | | 443 | | } |
| | | 444 | | |
| | 0 | 445 | | return sb.ToString(); |
| | | 446 | | } |
| | | 447 | | |
| | | 448 | | private static async Task AddFileToArchive(ZipArchive archive, string path, string content) |
| | | 449 | | { |
| | 0 | 450 | | var entry = archive.CreateEntry(path, CompressionLevel.Optimal); |
| | 0 | 451 | | using var stream = entry.Open(); |
| | 0 | 452 | | using var writer = new StreamWriter(stream, Encoding.UTF8); |
| | 0 | 453 | | await writer.WriteAsync(content); |
| | 0 | 454 | | } |
| | | 455 | | |
| | | 456 | | private static string SanitizeFileName(string name) |
| | | 457 | | { |
| | 0 | 458 | | if (string.IsNullOrWhiteSpace(name)) |
| | 0 | 459 | | return "Untitled"; |
| | | 460 | | |
| | | 461 | | // Replace invalid filename characters |
| | 0 | 462 | | var invalid = Path.GetInvalidFileNameChars(); |
| | 0 | 463 | | var sanitized = new StringBuilder(name); |
| | 0 | 464 | | foreach (var c in invalid) |
| | | 465 | | { |
| | 0 | 466 | | sanitized.Replace(c, '_'); |
| | | 467 | | } |
| | | 468 | | |
| | | 469 | | // Also replace some characters that might cause issues |
| | 0 | 470 | | sanitized.Replace('/', '_'); |
| | 0 | 471 | | sanitized.Replace('\\', '_'); |
| | 0 | 472 | | sanitized.Replace(':', '_'); |
| | | 473 | | |
| | 0 | 474 | | var result = sanitized.ToString().Trim(); |
| | | 475 | | |
| | | 476 | | // Limit length |
| | 0 | 477 | | if (result.Length > 100) |
| | 0 | 478 | | result = result.Substring(0, 100); |
| | | 479 | | |
| | 0 | 480 | | return string.IsNullOrWhiteSpace(result) ? "Untitled" : result; |
| | | 481 | | } |
| | | 482 | | |
| | | 483 | | private static string EscapeYaml(string value) |
| | | 484 | | { |
| | 0 | 485 | | if (string.IsNullOrEmpty(value)) |
| | 0 | 486 | | return value; |
| | | 487 | | |
| | 0 | 488 | | return value |
| | 0 | 489 | | .Replace("\\", "\\\\") |
| | 0 | 490 | | .Replace("\"", "\\\"") |
| | 0 | 491 | | .Replace("\n", "\\n") |
| | 0 | 492 | | .Replace("\r", ""); |
| | | 493 | | } |
| | | 494 | | } |