Wp2ghost/CSharpGhostImporter/CommentBlockBuilder.cs

141 lines
7.3 KiB
C#
Raw Normal View History

2026-10-14 19:12:29 +02:00
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<string> 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<WpComment>? comments) => (comments ?? Enumerable.Empty<WpComment>()).Count(IsRealComment);
/// <summary>Hängt den Kommentar-Block an (einmalig — ein vorhandener Block wird nicht verdoppelt).</summary>
public static string Append(string html, IEnumerable<WpComment>? 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<WpComment>? comments)
{
var real = (comments ?? Enumerable.Empty<WpComment>())
.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("<!--kg-card-begin: html-->\n");
sb.Append($"<section class=\"{BlockClass}\">\n");
sb.Append($"<h2 class=\"wp-comments__title\">Kommentare aus dem alten Blog ({real.Count})</h2>\n");
AppendList(sb, topLevel, repliesByParent, isReplies: false, depth: 0);
sb.Append("</section>\n");
sb.Append("<!--kg-card-end: html-->");
return sb.ToString();
}
private static void AppendList(StringBuilder sb, IEnumerable<WpComment> comments, ILookup<int, WpComment> repliesByParent, bool isReplies, int depth)
{
sb.Append(isReplies ? "<ol class=\"wp-comments__list wp-comments__replies\">\n" : "<ol class=\"wp-comments__list\">\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($"<li class=\"wp-comment\" id=\"wp-comment-{comment.Id}\">\n");
sb.Append($"<div class=\"wp-comment__meta\"><span class=\"wp-comment__author\">{name}</span>");
if (date.HasValue)
sb.Append($" <time class=\"wp-comment__date\" datetime=\"{date.Value:yyyy-MM-dd}\">{date.Value.ToString("d. MMMM yyyy", German)}</time>");
sb.Append("</div>\n");
sb.Append($"<div class=\"wp-comment__text\">{SanitizeContent(comment.Content ?? "")}</div>\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("</li>\n");
}
sb.Append("</ol>\n");
}
private static DateTime? ParseDate(string? date) =>
DateTime.TryParse(date, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ? parsed : null;
/// <summary>
/// Kommentartext ist fremde Eingabe: nur einfache Formatierung bleibt erhalten,
/// alles andere (Skripte, Attribute, unbekannte Tags) wird entfernt.
/// </summary>
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("<p", StringComparison.OrdinalIgnoreCase)) return html;
// Wie WordPress: Leerzeile = neuer Absatz, Zeilenumbruch = <br>
var paragraphs = Regex.Split(html, @"\n\s*\n").Select(p => p.Trim()).Where(p => p.Length > 0);
return string.Concat(paragraphs.Select(p => $"<p>{p.Replace("\n", "<br>")}</p>"));
}
}
}