diff --git a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs
index d7e9c818..02891ab3 100644
--- a/src/ClaudeDo.Worker/Tickets/TicketImportService.cs
+++ b/src/ClaudeDo.Worker/Tickets/TicketImportService.cs
@@ -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;
+
+ ///
+ /// Wandelt das Rich-Text-HTML des Ticketsystems in das Markdown-Subset von MarkdownView.
+ /// Zeilenumbrüche werden zu Absätzen, weil MarkdownView einfache Umbrüche zusammenzieht.
+ ///
+ // 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, @"]*>(.*?)",
+ 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, @"]*>(.*?)", m => Wrap(m.Groups[1].Value, "`"), Rx);
+ s = Regex.Replace(s, @"]*href=""([^""]*)""[^>]*>(.*?)",
+ 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, @"
]*>(.*?)
", m =>
+ {
+ var n = 0;
+ return "\n\n" + Regex.Replace(m.Groups[1].Value, @"]*>", _ => $"\n{++n}. ", Rx) + "\n\n";
+ }, Rx);
+ s = Regex.Replace(s, @"]*>", "\n- ", Rx);
+ s = Regex.Replace(s, @"?(ul|ol)\b[^>]*>", "\n\n", Rx);
+ s = Regex.Replace(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}";
}
}
diff --git a/tests/ClaudeDo.Worker.Tests/Tickets/TicketHtmlToMarkdownTests.cs b/tests/ClaudeDo.Worker.Tests/Tickets/TicketHtmlToMarkdownTests.cs
new file mode 100644
index 00000000..e2748d85
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Tickets/TicketHtmlToMarkdownTests.cs
@@ -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(
+ "Lager -> Adressprüfung aus
Zeile 1
Zeile 2
Ende
");
+
+ 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(
+ "Offene Punkte:
" +
+ "- Erst
- Dann
Link");
+
+ 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 "));
+}