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}";
}
}
@@ -0,0 +1,30 @@
using ClaudeDo.Worker.Tickets;
namespace ClaudeDo.Worker.Tests.Tickets;
public sealed class TicketHtmlToMarkdownTests
{
[Fact]
public void Paragraphs_breaks_and_entities_become_markdown_paragraphs()
{
var md = TicketImportService.HtmlToMarkdown(
"<p>Lager -&gt; Adressprüfung&nbsp;aus</p>Zeile 1<br>Zeile 2<div><br></div><div>Ende</div>");
Assert.Equal("Lager -> Adressprüfung aus\n\nZeile 1\n\nZeile 2\n\nEnde", md);
}
[Fact]
public void Lists_and_inline_formatting_map_to_the_MarkdownView_subset()
{
var md = TicketImportService.HtmlToMarkdown(
"<p>Offene Punkte:</p><ul><li class=\"MsoNormal\"><b>Band</b> sync</li><li>Waage</li></ul>" +
"<ol><li>Erst</li><li><em>Dann</em></li></ol><a href=\"http://x\">Link</a>");
Assert.Equal(
"Offene Punkte:\n\n- **Band** sync\n- Waage\n\n1. Erst\n2. *Dann*\n\nLink (http://x)", md);
}
[Fact]
public void Plain_text_passes_through()
=> Assert.Equal("Ticket ohne HTML", TicketImportService.HtmlToMarkdown(" Ticket ohne HTML "));
}