140 lines
7.3 KiB
C#
140 lines
7.3 KiB
C#
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>"));
|
|
}
|
|
}
|
|
}
|