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.*?", "", 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[^>]*>(.*?)", m => Wrap(m.Groups[2].Value, "**"), Rx); + s = Regex.Replace(s, @"<(i|em)\b[^>]*>(.*?)", 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, @"]*>", "\n\n", Rx); + s = Regex.Replace(s, @"|]*>", "\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:

  • Band sync
  • Waage
" + + "
  1. Erst
  2. 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 ")); +}