using System.Globalization; using System.Net; using System.Text; using System.Text.RegularExpressions; using HtmlAgilityPack; namespace wp2ghost { // ============================================================================= // CommentBlockBuilder — WordPress-Kommentare als lesbaren Block ans Beitragsende hängen. // Ghost bietet keinen Weg, Kommentare zu importieren; so bleiben Name, Datum und Text // erhalten, ohne dass Mitglieder angelegt oder E-Mail-Adressen übertragen werden. // ============================================================================= static class CommentBlockBuilder { // Kennzeichnet den Block im HTML (und ist die CSS-Klasse für das Theme) private const string BlockClass = "wp-comments"; private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-AT"); private static readonly HashSet AllowedTags = new() { "a", "p", "br", "strong", "b", "em", "i", "blockquote", "code" }; // Pingbacks ("[…] Auszug […]") und Fediverse-Reaktionen ("… gefiel dies!") sind keine Kommentare private static readonly Regex PingbackRegex = new(@"^\[(…|\.\.\.)\]", RegexOptions.Compiled); private static readonly Regex ReactionRegex = new(@"^(…|\.\.\.)\s*(gefiel dies|hat dies repostet)", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static bool IsRealComment(WpComment comment) { // Neuere Plugin-Version liefert den Typ mit if (!string.IsNullOrEmpty(comment.Type)) return comment.Type == "comment"; string text = WebUtility.HtmlDecode(comment.Content ?? "").Trim(); return text.Length > 0 && !PingbackRegex.IsMatch(text) && !ReactionRegex.IsMatch(text); } public static int CountRealComments(IEnumerable? comments) => (comments ?? Enumerable.Empty()).Count(IsRealComment); /// Hängt den Kommentar-Block an (einmalig — ein vorhandener Block wird nicht verdoppelt). public static string Append(string html, IEnumerable? comments) { if (html.Contains($"class=\"{BlockClass}\"")) return html; string block = Build(comments); return block.Length > 0 ? html + "\n" + block : html; } public static string Build(IEnumerable? comments) { var real = (comments ?? Enumerable.Empty()) .Where(IsRealComment) .OrderBy(c => ParseDate(c.Date) ?? DateTime.MaxValue) .ThenBy(c => c.Id) .ToList(); if (real.Count == 0) return ""; // Antworten unter ihren Kommentar hängen; fehlt der Bezug, steht der Kommentar auf oberster Ebene var ids = real.Select(c => c.Id).ToHashSet(); var repliesByParent = real.Where(c => c.Parent > 0 && c.Parent != c.Id && ids.Contains(c.Parent)).ToLookup(c => c.Parent); var topLevel = real.Where(c => !(c.Parent > 0 && c.Parent != c.Id && ids.Contains(c.Parent))); var sb = new StringBuilder(); // HTML-Karte: Ghost übernimmt den Inhalt unverändert sb.Append("\n"); sb.Append($"
\n"); sb.Append($"

Kommentare aus dem alten Blog ({real.Count})

\n"); AppendList(sb, topLevel, repliesByParent, isReplies: false, depth: 0); sb.Append("
\n"); sb.Append(""); return sb.ToString(); } private static void AppendList(StringBuilder sb, IEnumerable comments, ILookup repliesByParent, bool isReplies, int depth) { sb.Append(isReplies ? "
    \n" : "
      \n"); foreach (var comment in comments) { string name = WebUtility.HtmlEncode(WebUtility.HtmlDecode(comment.AuthorName ?? "").Trim()); if (name.Length == 0) name = "Anonym"; var date = ParseDate(comment.Date); sb.Append($"
    1. \n"); sb.Append($"
      {name}"); if (date.HasValue) sb.Append($" "); sb.Append("
      \n"); sb.Append($"
      {SanitizeContent(comment.Content ?? "")}
      \n"); // Tiefe begrenzen, falls die Bezüge in WordPress einen Kreis bilden if (depth < 10 && repliesByParent[comment.Id].Any()) AppendList(sb, repliesByParent[comment.Id], repliesByParent, isReplies: true, depth + 1); sb.Append("
    2. \n"); } sb.Append("
    \n"); } private static DateTime? ParseDate(string? date) => DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : null; /// /// Kommentartext ist fremde Eingabe: nur einfache Formatierung bleibt erhalten, /// alles andere (Skripte, Attribute, unbekannte Tags) wird entfernt. /// private static string SanitizeContent(string content) { var doc = new HtmlDocument(); doc.LoadHtml(content); foreach (var node in doc.DocumentNode.Descendants().Where(n => n.NodeType == HtmlNodeType.Comment || n.Name is "script" or "style").ToList()) node.Remove(); foreach (var element in doc.DocumentNode.Descendants().Where(n => n.NodeType == HtmlNodeType.Element).ToList()) { if (!AllowedTags.Contains(element.Name)) { // Tag entfernen, Inhalt behalten foreach (var child in element.ChildNodes.ToList()) element.ParentNode!.InsertBefore(child, element); element.Remove(); continue; } string href = element.Name == "a" ? HtmlEntity.DeEntitize(element.GetAttributeValue("href", "")).Trim() : ""; element.Attributes.RemoveAll(); if (element.Name != "a") continue; if (href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || href.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { element.SetAttributeValue("href", href); element.SetAttributeValue("rel", "nofollow noopener"); } } string html = doc.DocumentNode.InnerHtml.Replace("\r\n", "\n").Trim(); if (html.Contains(" var paragraphs = Regex.Split(html, @"\n\s*\n").Select(p => p.Trim()).Where(p => p.Length > 0); return string.Concat(paragraphs.Select(p => $"

    {p.Replace("\n", "
    ")}

    ")); } } }