fix(tickets): convert ticket HTML to markdown on import

This commit is contained in:
mika kuns
2026-09-30 08:39:17 +02:00
parent ccd11a8033
commit 27d2b1d8ef
2 changed files with 71 additions and 3 deletions
@@ -1,3 +1,5 @@
using System.Net;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -73,7 +75,7 @@ public sealed class TicketImportService
{
Id = taskId,
ListId = listId,
Title = ticket.Title,
Title = WebUtility.HtmlDecode(ticket.Title).Trim(),
Description = BuildDescription(ticket),
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
@@ -102,7 +104,43 @@ public sealed class TicketImportService
private static string BuildDescription(TicketBoardItem ticket)
{
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
return body + $"Ticket #{ticket.Id}";
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : HtmlToMarkdown(ticket.Description);
return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}";
}
private const RegexOptions Rx = RegexOptions.IgnoreCase | RegexOptions.Singleline;
/// <summary>
/// Wandelt das Rich-Text-HTML des Ticketsystems in das Markdown-Subset von <c>MarkdownView</c>.
/// Zeilenumbrüche werden zu Absätzen, weil <c>MarkdownView</c> einfache Umbrüche zusammenzieht.
/// </summary>
// ponytail: Regex statt HTML-Parser — verschachtelte Listen und Tabellen werden flach; AngleSharp, falls die Tickets das brauchen
internal static string HtmlToMarkdown(string html)
{
var s = Regex.Replace(html, @"\s*\r?\n\s*", " ");
s = Regex.Replace(s, @"<(script|style)\b.*?</\1>", "", Rx);
s = Regex.Replace(s, @"<h([1-6])\b[^>]*>(.*?)</h\1>",
m => $"\n\n{new string('#', Math.Min(int.Parse(m.Groups[1].Value), 3))} {m.Groups[2].Value}\n\n", Rx);
s = Regex.Replace(s, @"<(b|strong)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "**"), Rx);
s = Regex.Replace(s, @"<(i|em)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "*"), Rx);
s = Regex.Replace(s, @"<code\b[^>]*>(.*?)</code>", m => Wrap(m.Groups[1].Value, "`"), Rx);
s = Regex.Replace(s, @"<a\b[^>]*href=""([^""]*)""[^>]*>(.*?)</a>",
m => m.Groups[2].Value == m.Groups[1].Value ? m.Groups[1].Value : $"{m.Groups[2].Value} ({m.Groups[1].Value})", Rx);
s = Regex.Replace(s, @"<ol\b[^>]*>(.*?)</ol>", m =>
{
var n = 0;
return "\n\n" + Regex.Replace(m.Groups[1].Value, @"<li\b[^>]*>", _ => $"\n{++n}. ", Rx) + "\n\n";
}, Rx);
s = Regex.Replace(s, @"<li\b[^>]*>", "\n- ", Rx);
s = Regex.Replace(s, @"</?(ul|ol)\b[^>]*>", "\n\n", Rx);
s = Regex.Replace(s, @"<br\s*/?>|</?(p|div|blockquote|tr)\b[^>]*>", "\n\n", Rx);
s = Regex.Replace(s, @"<[^>]+>", "");
s = WebUtility.HtmlDecode(s).Replace((char)0xA0, ' ');
var lines = s.Split('\n').Select(l => Regex.Replace(l, @"[ \t]+", " ").Trim());
return Regex.Replace(string.Join('\n', lines), @"\n{3,}", "\n\n").Trim();
static string Wrap(string inner, string mark)
=> string.IsNullOrWhiteSpace(inner) ? inner : $"{mark}{inner.Trim()}{mark}";
}
}