fix(tickets): convert ticket HTML to markdown on import
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -73,7 +75,7 @@ public sealed class TicketImportService
|
|||||||
{
|
{
|
||||||
Id = taskId,
|
Id = taskId,
|
||||||
ListId = listId,
|
ListId = listId,
|
||||||
Title = ticket.Title,
|
Title = WebUtility.HtmlDecode(ticket.Title).Trim(),
|
||||||
Description = BuildDescription(ticket),
|
Description = BuildDescription(ticket),
|
||||||
Status = TaskStatus.Idle,
|
Status = TaskStatus.Idle,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -102,7 +104,43 @@ public sealed class TicketImportService
|
|||||||
|
|
||||||
private static string BuildDescription(TicketBoardItem ticket)
|
private static string BuildDescription(TicketBoardItem ticket)
|
||||||
{
|
{
|
||||||
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
|
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : HtmlToMarkdown(ticket.Description);
|
||||||
return body + $"Ticket #{ticket.Id}";
|
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 -> Adressprüfung 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 "));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user