2026-09-20 08:31:23 +02:00
|
|
|
using System.Globalization;
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
using HtmlAgilityPack;
|
|
|
|
|
|
|
|
|
|
namespace wp2ghost
|
|
|
|
|
{
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// HtmlGhostConverter — WordPress HTML zu Ghost-kompatibles HTML konvertieren
|
|
|
|
|
// =============================================================================
|
|
|
|
|
public static class HtmlGhostConverter
|
|
|
|
|
{
|
|
|
|
|
private static readonly Dictionary<string, string?> WpClassMap = new(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
{
|
|
|
|
|
// Block-Elemente → Ghost-Äquivalent oder null (Klasse entfernen)
|
|
|
|
|
{ "wp-block-paragraph", null },
|
|
|
|
|
{ "wp-block-heading", null },
|
|
|
|
|
{ "wp-block-list", null },
|
|
|
|
|
{ "wp-block-quote", null },
|
|
|
|
|
{ "wp-block-code", null },
|
|
|
|
|
{ "wp-block-separator", null },
|
|
|
|
|
{ "wp-block-group", null },
|
|
|
|
|
{ "wp-block-columns", null },
|
|
|
|
|
{ "wp-block-column", null },
|
|
|
|
|
{ "wp-block-freeform", null },
|
|
|
|
|
{ "wp-block-verse", null },
|
|
|
|
|
{ "wp-block-preformatted", null },
|
|
|
|
|
|
|
|
|
|
// Ghost-Kompatible Klassen
|
|
|
|
|
{ "wp-block-image", "kg-card kg-image-card" },
|
|
|
|
|
{ "wp-block-gallery", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-pullquote", "kg-card kg-pullquote" },
|
|
|
|
|
{ "wp-block-embed", "kg-card kg-embed-card" },
|
|
|
|
|
{ "wp-block-embed-wrapper", "kg-card kg-embed-card" },
|
|
|
|
|
{ "wp-block-video", "kg-card kg-embed-card" },
|
|
|
|
|
{ "wp-block-audio", "kg-card kg-embed-card" },
|
|
|
|
|
{ "wp-block-cover", "kg-card kg-header-card" },
|
|
|
|
|
{ "wp-block-media-text", "kg-card kg-width-wide" },
|
|
|
|
|
{ "wp-block-table", "kg-card kg-table-card" },
|
|
|
|
|
{ "wp-block-button", "kg-card kg-button-card" },
|
|
|
|
|
{ "wp-block-buttons", "kg-card kg-button-card" },
|
|
|
|
|
{ "wp-block-file", "kg-card kg-file-card" },
|
|
|
|
|
|
|
|
|
|
// Sub-Elemente
|
|
|
|
|
{ "wp-block-button__link", "gh-btn" },
|
|
|
|
|
{ "wp-block-button__link__inline", "gh-btn" },
|
|
|
|
|
{ "wp-element-button", "gh-btn" },
|
|
|
|
|
{ "has-text-align-center", "kg-width-wide" },
|
|
|
|
|
{ "has-text-align-left", "" },
|
|
|
|
|
{ "has-text-align-right", "" },
|
|
|
|
|
{ "is-style-wide", "kg-width-wide" },
|
|
|
|
|
{ "is-style-full", "kg-width-wide" },
|
|
|
|
|
{ "is-style-large", "kg-width-wide" },
|
|
|
|
|
|
|
|
|
|
// Theme-spezifisch
|
|
|
|
|
{ "wp-block-post-title", null },
|
|
|
|
|
{ "wp-block-post-content", null },
|
|
|
|
|
{ "wp-block-site-title", null },
|
|
|
|
|
{ "wp-block-site-tagline", null },
|
|
|
|
|
{ "wp-block-query", null },
|
|
|
|
|
{ "wp-block-comments", null },
|
|
|
|
|
{ "wp-block-comment-template", null },
|
|
|
|
|
{ "wp-block-template-part", null },
|
|
|
|
|
|
|
|
|
|
// CoBlocks
|
|
|
|
|
{ "wp-block-coblocks-gallery-stacked", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-coblocks-gallery-collage", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-coblocks-gallery-masonry", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-coblocks-gallery-thumbnails", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-coblocks-alert", "kg-card kg-alert-card" },
|
|
|
|
|
{ "wp-block-coblocks-accordion", "kg-card kg-accordion-card" },
|
|
|
|
|
{ "wp-block-coblocks-accordion-item", "kg-accordion-item" },
|
|
|
|
|
{ "wp-block-coblocks-row", "kg-width-wide" },
|
|
|
|
|
{ "wp-block-coblocks-column", "" },
|
|
|
|
|
{ "wp-block-coblocks-click-to-tweet", "kg-card kg-click-to-tweet" },
|
|
|
|
|
{ "wp-block-coblocks-social-profiles", "kg-card kg-social-link" },
|
|
|
|
|
|
|
|
|
|
// Jetpack
|
|
|
|
|
{ "wp-block-jetpack-tiled-gallery", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-jetpack-slideshow", "kg-card kg-gallery-card" },
|
|
|
|
|
{ "wp-block-jetpack-story", "kg-card kg-gallery-card" },
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// Ghost-Galerien fassen maximal 9 Bilder (3 pro Zeile) — größere WordPress-Galerien
|
|
|
|
|
// werden in mehrere aufeinanderfolgende Ghost-Galerien aufgeteilt.
|
|
|
|
|
private const int MaxGalleryImages = 9;
|
|
|
|
|
private const int GalleryImagesPerRow = 3;
|
|
|
|
|
|
|
|
|
|
// Liefert die echten Maße eines bereits heruntergeladenen Bildes (per src-URL)
|
|
|
|
|
private static Func<string, (int Width, int Height)?>? _imageSizeResolver;
|
|
|
|
|
private static int _ghostGalleryCount;
|
|
|
|
|
private static int _skippedGalleryImages;
|
|
|
|
|
private static int _captionedGalleryImages;
|
|
|
|
|
|
|
|
|
|
public static string Convert(string html, Func<string, (int Width, int Height)?>? imageSizeResolver = null)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
_imageSizeResolver = imageSizeResolver;
|
|
|
|
|
_ghostGalleryCount = 0;
|
|
|
|
|
_skippedGalleryImages = 0;
|
|
|
|
|
_captionedGalleryImages = 0;
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
var htmlDoc = new HtmlDocument();
|
|
|
|
|
htmlDoc.LoadHtml(html);
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
RemoveJunkCaptions(htmlDoc);
|
|
|
|
|
|
|
|
|
|
int brokenImages = RemoveBrokenImages(htmlDoc);
|
|
|
|
|
if (brokenImages > 0)
|
|
|
|
|
Console.WriteLine($" [Warnung] {brokenImages} Bild(er) mit blob:-Quelle entfernt (schon in WordPress defekt)");
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
int totalGalleries = 0;
|
|
|
|
|
totalGalleries += ConvertClassicGalleries(htmlDoc);
|
2026-10-14 19:12:29 +02:00
|
|
|
|
|
|
|
|
// Gutenberg Gallery
|
|
|
|
|
totalGalleries += ConvertImageGalleries(htmlDoc,
|
|
|
|
|
"//figure[contains(@class, 'wp-block-gallery')] | " +
|
|
|
|
|
"//div[contains(@class, 'wp-block-gallery')] | " +
|
|
|
|
|
"//ul[contains(@class, 'wp-block-gallery')]");
|
|
|
|
|
|
|
|
|
|
// Jetpack Tiled Gallery
|
|
|
|
|
totalGalleries += ConvertImageGalleries(htmlDoc,
|
|
|
|
|
"//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " +
|
|
|
|
|
"//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]");
|
|
|
|
|
|
|
|
|
|
// CoBlocks Gallery
|
|
|
|
|
totalGalleries += ConvertImageGalleries(htmlDoc,
|
|
|
|
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-masonry')] | " +
|
|
|
|
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-stacked')] | " +
|
|
|
|
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-collage')] | " +
|
|
|
|
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-thumbnails')]");
|
2026-09-20 08:31:23 +02:00
|
|
|
|
|
|
|
|
if (totalGalleries > 0)
|
2026-10-14 19:12:29 +02:00
|
|
|
{
|
|
|
|
|
string split = _ghostGalleryCount > totalGalleries ? $" → {_ghostGalleryCount} Ghost-Galerien (max. {MaxGalleryImages} Bilder)" : "";
|
|
|
|
|
Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert{split}");
|
|
|
|
|
}
|
|
|
|
|
if (_captionedGalleryImages > 0)
|
|
|
|
|
Console.WriteLine($" [Gallery] {_captionedGalleryImages} Galerie-Bild(er) mit Unterschrift als Einzelbild ausgegeben");
|
|
|
|
|
if (_skippedGalleryImages > 0)
|
|
|
|
|
Console.WriteLine($" [Warnung] {_skippedGalleryImages} Galerie-Bild(er) ohne gültige Bildquelle (data:) ausgelassen");
|
2026-09-20 08:31:23 +02:00
|
|
|
|
|
|
|
|
ConvertSingleImages(htmlDoc);
|
|
|
|
|
ConvertFigures(htmlDoc);
|
|
|
|
|
ConvertWpClasses(htmlDoc);
|
2026-09-22 08:08:51 +02:00
|
|
|
AddTargetBlankToExternalLinks(htmlDoc);
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
_imageSizeResolver = null;
|
2026-09-20 08:31:23 +02:00
|
|
|
return htmlDoc.DocumentNode.OuterHtml;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// =========================================================================
|
|
|
|
|
// Bilder mit blob:-Quelle entfernen: der Upload ist in WordPress nie
|
|
|
|
|
// abgeschlossen worden, es gibt keine Datei, die übernommen werden könnte.
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static int RemoveBrokenImages(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var imgs = htmlDoc.DocumentNode.SelectNodes("//img[starts-with(@src, 'blob:')]");
|
|
|
|
|
if (imgs == null) return 0;
|
|
|
|
|
|
|
|
|
|
int count = 0;
|
|
|
|
|
foreach (var img in imgs.ToList())
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrEmpty(GetBestImageUrl(img)) && !GetBestImageUrl(img).StartsWith("blob:"))
|
|
|
|
|
continue; // data-orig-file/srcset liefern noch eine echte Quelle
|
|
|
|
|
|
|
|
|
|
// Umschließende Einzelbild-Figure mit entfernen, wenn sie nur dieses Bild enthält
|
|
|
|
|
var figure = img.Ancestors("figure").FirstOrDefault();
|
|
|
|
|
HtmlNode toRemove = figure != null && figure.SelectNodes(".//img")?.Count == 1 ? figure : img;
|
|
|
|
|
toRemove.Remove();
|
|
|
|
|
count++;
|
|
|
|
|
}
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Platzhalter-Unterschriften entfernen, die die Kamera/Handy-App automatisch
|
|
|
|
|
// einträgt ("default") — sie gelten für kein Bild.
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static void RemoveJunkCaptions(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var captions = htmlDoc.DocumentNode.SelectNodes("//figcaption");
|
|
|
|
|
if (captions == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var caption in captions.ToList())
|
|
|
|
|
{
|
|
|
|
|
string text = HtmlEntity.DeEntitize(caption.InnerText).Trim();
|
|
|
|
|
if (text.Equals("default", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
caption.Remove();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? GetCaptionHtml(HtmlNode? figcaption)
|
|
|
|
|
{
|
|
|
|
|
if (figcaption == null || string.IsNullOrWhiteSpace(HtmlEntity.DeEntitize(figcaption.InnerText)))
|
|
|
|
|
return null;
|
|
|
|
|
return figcaption.InnerHtml.Trim();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
// =========================================================================
|
|
|
|
|
// Gallery-Typ 1: WordPress Classic Gallery
|
|
|
|
|
// HTML: <div class='gallery gallery-columns-3'>
|
|
|
|
|
// <figure class='gallery-item'>
|
|
|
|
|
// <div class='gallery-icon portrait'>
|
|
|
|
|
// <a href='...'><img src='thumb' data-orig-file='full.jpg' /></a>
|
|
|
|
|
// </div>
|
|
|
|
|
// </figure>
|
|
|
|
|
// </div>
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static int ConvertClassicGalleries(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
|
|
|
|
|
"//div[contains(@class, 'gallery') and not(contains(@class, 'wp-block'))]"
|
|
|
|
|
);
|
|
|
|
|
if (galleryNodes == null) return 0;
|
|
|
|
|
|
|
|
|
|
int count = 0;
|
|
|
|
|
foreach (var gallery in galleryNodes.ToList())
|
|
|
|
|
{
|
|
|
|
|
var items = gallery.SelectNodes(".//figure[contains(@class, 'gallery-item')]");
|
|
|
|
|
if (items == null || items.Count == 0) continue;
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
var images = new List<(HtmlNode Wrapper, string? Caption)>();
|
2026-09-20 08:31:23 +02:00
|
|
|
foreach (var item in items)
|
|
|
|
|
{
|
|
|
|
|
var img = item.SelectSingleNode(".//img");
|
|
|
|
|
if (img == null) continue;
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
var wrapper = TryBuildGalleryImage(htmlDoc, img);
|
|
|
|
|
if (wrapper != null)
|
|
|
|
|
images.Add((wrapper, GetCaptionHtml(item.SelectSingleNode(".//figcaption"))));
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
if (images.Count == 0) continue;
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
ReplaceWithGhostGalleries(htmlDoc, gallery, images);
|
2026-09-20 08:31:23 +02:00
|
|
|
count++;
|
|
|
|
|
}
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
2026-10-14 19:12:29 +02:00
|
|
|
// Gallery-Typ 2-4: Gutenberg, Jetpack Tiled, CoBlocks — alle <img> des Blocks
|
2026-09-20 08:31:23 +02:00
|
|
|
// =========================================================================
|
2026-10-14 19:12:29 +02:00
|
|
|
private static int ConvertImageGalleries(HtmlDocument htmlDoc, string xpath)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(xpath);
|
2026-09-20 08:31:23 +02:00
|
|
|
if (galleryNodes == null) return 0;
|
|
|
|
|
|
|
|
|
|
int count = 0;
|
|
|
|
|
foreach (var gallery in galleryNodes.ToList())
|
|
|
|
|
{
|
|
|
|
|
var imgs = gallery.SelectNodes(".//img");
|
|
|
|
|
if (imgs == null || imgs.Count == 0) continue;
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
var images = new List<(HtmlNode Wrapper, string? Caption)>();
|
2026-09-20 08:31:23 +02:00
|
|
|
foreach (var img in imgs)
|
|
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
var wrapper = TryBuildGalleryImage(htmlDoc, img);
|
|
|
|
|
if (wrapper == null) continue;
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// Unterschrift des einzelnen Bildes: figcaption seiner eigenen Figure (nicht der Galerie)
|
|
|
|
|
var imageFigure = img.Ancestors().TakeWhile(a => a != gallery).FirstOrDefault(a => a.Name == "figure");
|
|
|
|
|
images.Add((wrapper, GetCaptionHtml(imageFigure?.SelectSingleNode(".//figcaption"))));
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
if (images.Count == 0) continue;
|
2026-09-21 08:24:28 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
ReplaceWithGhostGalleries(htmlDoc, gallery, images);
|
2026-09-20 08:31:23 +02:00
|
|
|
count++;
|
|
|
|
|
}
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
2026-10-14 19:12:29 +02:00
|
|
|
// Galerie-Helfer: Aufteilen, Unterschriften, Wide-Klasse, Bild-Wrapper, Zeilen
|
2026-09-20 08:31:23 +02:00
|
|
|
// =========================================================================
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Ersetzt eine WordPress-Galerie durch Ghost-Karten in der ursprünglichen Bildreihenfolge.
|
|
|
|
|
/// Eine Ghost-Galerie hat nur EINE Unterschrift für alle Bilder; damit jede Unterschrift
|
|
|
|
|
/// bei ihrem Bild bleibt, wird ein Bild mit Unterschrift als eigenes Einzelbild ausgegeben
|
|
|
|
|
/// und die Bilder davor/danach bilden jeweils eigene Galerien (max. 9 Bilder).
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static void ReplaceWithGhostGalleries(HtmlDocument htmlDoc, HtmlNode gallery, List<(HtmlNode Wrapper, string? Caption)> images)
|
|
|
|
|
{
|
|
|
|
|
var parent = gallery.ParentNode;
|
|
|
|
|
if (parent == null) return;
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
string? galleryCaption = TakeGalleryCaption(gallery);
|
|
|
|
|
HtmlNode? lastGallery = null;
|
|
|
|
|
var run = new List<HtmlNode>();
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
void FlushRun()
|
|
|
|
|
{
|
|
|
|
|
if (run.Count == 1)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
parent.InsertBefore(BuildImageCard(htmlDoc, run[0], null), gallery);
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
2026-10-14 19:12:29 +02:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
foreach (var chunk in SplitGallery(run))
|
|
|
|
|
{
|
|
|
|
|
var ghostGallery = htmlDoc.CreateElement("figure");
|
|
|
|
|
SetClasses(ghostGallery, "kg-card kg-gallery-card");
|
|
|
|
|
AddWideClass(ghostGallery, gallery);
|
|
|
|
|
|
|
|
|
|
var container = htmlDoc.CreateElement("div");
|
|
|
|
|
container.AddClass("kg-gallery-container");
|
|
|
|
|
AppendGalleryRows(htmlDoc, container, chunk, GalleryImagesPerRow);
|
|
|
|
|
ghostGallery.AppendChild(container);
|
|
|
|
|
|
|
|
|
|
parent.InsertBefore(ghostGallery, gallery);
|
|
|
|
|
lastGallery = ghostGallery;
|
|
|
|
|
_ghostGalleryCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
run.Clear();
|
|
|
|
|
}
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
foreach (var (wrapper, caption) in images)
|
|
|
|
|
{
|
|
|
|
|
if (caption == null)
|
|
|
|
|
{
|
|
|
|
|
run.Add(wrapper);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
FlushRun();
|
|
|
|
|
parent.InsertBefore(BuildImageCard(htmlDoc, wrapper, caption), gallery);
|
|
|
|
|
_captionedGalleryImages++;
|
|
|
|
|
}
|
|
|
|
|
FlushRun();
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// Unterschrift der ganzen Galerie: an die letzte Teil-Galerie, sonst als Absatz dahinter
|
|
|
|
|
if (galleryCaption != null)
|
|
|
|
|
{
|
|
|
|
|
if (lastGallery != null)
|
|
|
|
|
{
|
|
|
|
|
var newCaption = htmlDoc.CreateElement("figcaption");
|
|
|
|
|
newCaption.InnerHtml = galleryCaption;
|
|
|
|
|
lastGallery.AppendChild(newCaption);
|
|
|
|
|
lastGallery.AddClass("kg-card-hascaption");
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
var paragraph = htmlDoc.CreateElement("p");
|
|
|
|
|
paragraph.InnerHtml = galleryCaption;
|
|
|
|
|
parent.InsertBefore(paragraph, gallery);
|
|
|
|
|
}
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
2026-10-14 19:12:29 +02:00
|
|
|
|
|
|
|
|
parent.RemoveChild(gallery);
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// Unterschrift der ganzen Galerie: direktes figcaption-Kind (Gutenberg) oder ein
|
|
|
|
|
// figcaption unmittelbar HINTER der Galerie (CoBlocks: class="blocks-gallery-caption").
|
|
|
|
|
private static string? TakeGalleryCaption(HtmlNode gallery)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
var captionNode = gallery.SelectSingleNode("./figcaption");
|
|
|
|
|
if (captionNode == null)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
var next = gallery.NextSibling;
|
|
|
|
|
while (next != null && next.NodeType != HtmlNodeType.Element && string.IsNullOrWhiteSpace(next.InnerText))
|
|
|
|
|
next = next.NextSibling;
|
|
|
|
|
if (next != null && next.Name == "figcaption")
|
|
|
|
|
captionNode = next;
|
|
|
|
|
}
|
|
|
|
|
if (captionNode == null) return null;
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
string? caption = GetCaptionHtml(captionNode);
|
|
|
|
|
captionNode.Remove();
|
|
|
|
|
return caption;
|
|
|
|
|
}
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
private static HtmlNode BuildImageCard(HtmlDocument htmlDoc, HtmlNode galleryImageWrapper, string? captionHtml)
|
|
|
|
|
{
|
|
|
|
|
var figure = htmlDoc.CreateElement("figure");
|
|
|
|
|
SetClasses(figure, "kg-card kg-image-card");
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
var img = galleryImageWrapper.SelectSingleNode("./img")!;
|
|
|
|
|
img.Remove();
|
|
|
|
|
figure.AppendChild(img);
|
2026-09-20 08:31:23 +02:00
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
if (!string.IsNullOrEmpty(captionHtml))
|
|
|
|
|
{
|
|
|
|
|
var caption = htmlDoc.CreateElement("figcaption");
|
|
|
|
|
caption.InnerHtml = captionHtml;
|
|
|
|
|
figure.AppendChild(caption);
|
|
|
|
|
figure.AddClass("kg-card-hascaption");
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
2026-10-14 19:12:29 +02:00
|
|
|
return figure;
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
private static List<List<HtmlNode>> SplitGallery(List<HtmlNode> wrappers)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
var chunks = new List<List<HtmlNode>>();
|
|
|
|
|
for (int i = 0; i < wrappers.Count;)
|
|
|
|
|
{
|
|
|
|
|
int remaining = wrappers.Count - i;
|
|
|
|
|
int take = Math.Min(MaxGalleryImages, remaining);
|
|
|
|
|
// Kein einzelnes Bild als letzte Galerie übrig lassen (10 → 8 + 2)
|
|
|
|
|
if (remaining - take == 1) take--;
|
|
|
|
|
chunks.Add(wrappers.GetRange(i, take));
|
|
|
|
|
i += take;
|
|
|
|
|
}
|
|
|
|
|
return chunks;
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AddWideClass(HtmlNode ghostGallery, HtmlNode originalGallery)
|
|
|
|
|
{
|
|
|
|
|
string origClass = originalGallery.GetAttributeValue("class", "");
|
|
|
|
|
if (origClass.Contains("is-style-wide") || origClass.Contains("alignwide"))
|
|
|
|
|
ghostGallery.AddClass("kg-width-wide");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
private static HtmlNode? TryBuildGalleryImage(HtmlDocument htmlDoc, HtmlNode img)
|
|
|
|
|
{
|
|
|
|
|
string src = GetBestImageUrl(img);
|
|
|
|
|
if (string.IsNullOrEmpty(src)) return null;
|
|
|
|
|
|
|
|
|
|
// data:-Bilder (Platzhalter) lassen sich nicht als Galerie-Bild exportieren
|
|
|
|
|
if (src.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
_skippedGalleryImages++;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BuildGalleryImage(htmlDoc, img, src);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
private static HtmlNode BuildGalleryImage(HtmlDocument htmlDoc, HtmlNode originalImg, string src)
|
|
|
|
|
{
|
|
|
|
|
var imgWrapper = htmlDoc.CreateElement("div");
|
|
|
|
|
imgWrapper.AddClass("kg-gallery-image");
|
|
|
|
|
|
|
|
|
|
var newImg = htmlDoc.CreateElement("img");
|
|
|
|
|
newImg.SetAttributeValue("src", src);
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// Ghost verwirft Galerie-Bilder ohne Breite/Höhe → möglichst die echten Maße der Datei verwenden
|
2026-09-20 08:31:23 +02:00
|
|
|
double w = 0, h = 0;
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
var realSize = _imageSizeResolver?.Invoke(src);
|
|
|
|
|
if (realSize.HasValue)
|
|
|
|
|
{
|
|
|
|
|
w = realSize.Value.Width;
|
|
|
|
|
h = realSize.Value.Height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (w <= 0 || h <= 0)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
string origSize = originalImg.GetAttributeValue("data-orig-size", "");
|
2026-09-20 08:31:23 +02:00
|
|
|
var parts = origSize.Split(',');
|
|
|
|
|
if (parts.Length == 2 &&
|
|
|
|
|
double.TryParse(parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double ow) &&
|
|
|
|
|
double.TryParse(parts[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double oh))
|
|
|
|
|
{
|
|
|
|
|
w = ow;
|
|
|
|
|
h = oh;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (w <= 0 || h <= 0)
|
|
|
|
|
{
|
|
|
|
|
if (double.TryParse(originalImg.GetAttributeValue("width", ""), NumberStyles.Float, CultureInfo.InvariantCulture, out double aw) &&
|
|
|
|
|
double.TryParse(originalImg.GetAttributeValue("height", ""), NumberStyles.Float, CultureInfo.InvariantCulture, out double ah))
|
|
|
|
|
{
|
|
|
|
|
w = aw;
|
|
|
|
|
h = ah;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
// Maße unbekannt (z.B. extern eingebundenes Bild): 3:2 annehmen, statt das Bild zu verlieren
|
|
|
|
|
if (w <= 0 || h <= 0)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
w = 1200;
|
|
|
|
|
h = 800;
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:12:29 +02:00
|
|
|
newImg.SetAttributeValue("width", ((int)w).ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
newImg.SetAttributeValue("height", ((int)h).ToString(CultureInfo.InvariantCulture));
|
|
|
|
|
string ratio = (w / h).ToString("G6", CultureInfo.InvariantCulture);
|
|
|
|
|
imgWrapper.SetAttributeValue("style", $"flex: {ratio} 1 0%;");
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
newImg.SetAttributeValue("loading", "lazy");
|
|
|
|
|
newImg.SetAttributeValue("alt", originalImg.GetAttributeValue("alt", ""));
|
|
|
|
|
|
|
|
|
|
imgWrapper.AppendChild(newImg);
|
|
|
|
|
return imgWrapper;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AppendGalleryRows(HtmlDocument htmlDoc, HtmlNode container, List<HtmlNode> wrappers, int columns)
|
|
|
|
|
{
|
|
|
|
|
for (int i = 0; i < wrappers.Count; i += columns)
|
|
|
|
|
{
|
|
|
|
|
var row = htmlDoc.CreateElement("div");
|
|
|
|
|
row.AddClass("kg-gallery-row");
|
|
|
|
|
|
|
|
|
|
int end = Math.Min(i + columns, wrappers.Count);
|
|
|
|
|
for (int j = i; j < end; j++)
|
|
|
|
|
row.AppendChild(wrappers[j]);
|
|
|
|
|
|
|
|
|
|
container.AppendChild(row);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Helper: Beste Bild-URL aus img-Element holen
|
|
|
|
|
// Priorität: data-orig-file → data-large-file → srcset (letzter/Widest) → src
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static string GetBestImageUrl(HtmlNode img)
|
|
|
|
|
{
|
|
|
|
|
// 1. data-orig-file (Vollbild)
|
|
|
|
|
string src = img.GetAttributeValue("data-orig-file", "");
|
|
|
|
|
if (!string.IsNullOrEmpty(src)) return src;
|
|
|
|
|
|
|
|
|
|
// 2. data-large-file
|
|
|
|
|
src = img.GetAttributeValue("data-large-file", "");
|
|
|
|
|
if (!string.IsNullOrEmpty(src)) return src;
|
|
|
|
|
|
|
|
|
|
// 3. srcset (größte Variante nehmen)
|
|
|
|
|
string srcset = img.GetAttributeValue("srcset", "");
|
|
|
|
|
if (!string.IsNullOrEmpty(srcset))
|
|
|
|
|
{
|
|
|
|
|
src = GetLargestFromSrcset(srcset);
|
|
|
|
|
if (!string.IsNullOrEmpty(src)) return src;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. src (Fallback)
|
|
|
|
|
return img.GetAttributeValue("src", "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetLargestFromSrcset(string srcset)
|
|
|
|
|
{
|
|
|
|
|
string bestUrl = "";
|
|
|
|
|
int bestWidth = 0;
|
|
|
|
|
|
|
|
|
|
foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
{
|
|
|
|
|
string trimmed = part.Trim();
|
|
|
|
|
string[] tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (tokens.Length < 2) continue;
|
|
|
|
|
|
|
|
|
|
string url = tokens[0];
|
|
|
|
|
string descriptor = tokens[1];
|
|
|
|
|
|
|
|
|
|
if (int.TryParse(descriptor.Replace("w", ""), out int width) && width > bestWidth)
|
|
|
|
|
{
|
|
|
|
|
bestWidth = width;
|
|
|
|
|
bestUrl = url;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bestUrl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Einzelbilder (nicht in Galerien)
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static void ConvertSingleImages(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var singleImgNodes = htmlDoc.DocumentNode.SelectNodes("//img");
|
|
|
|
|
if (singleImgNodes == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var img in singleImgNodes.ToList())
|
|
|
|
|
{
|
|
|
|
|
if (img.Ancestors("figure").Any(a => HasClass(a, "kg-gallery-card")))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
string src = GetBestImageUrl(img);
|
|
|
|
|
if (string.IsNullOrEmpty(src)) continue;
|
|
|
|
|
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
// Beste Variante als src setzen; srcset/sizes verweisen auf WordPress-Größenvarianten
|
|
|
|
|
img.SetAttributeValue("src", src);
|
|
|
|
|
img.Attributes.Remove("srcset");
|
|
|
|
|
img.Attributes.Remove("sizes");
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
var parentFigure = img.Ancestors("figure").FirstOrDefault();
|
|
|
|
|
if (parentFigure != null)
|
|
|
|
|
{
|
2026-10-14 19:12:29 +02:00
|
|
|
bool hasCaption = GetCaptionHtml(parentFigure.SelectSingleNode("./figcaption")) != null;
|
|
|
|
|
SetClasses(parentFigure, hasCaption ? "kg-card kg-image-card kg-card-hascaption" : "kg-card kg-image-card");
|
2026-09-20 08:31:23 +02:00
|
|
|
img.AddClass("kg-image");
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
var wrapper = htmlDoc.CreateElement("figure");
|
|
|
|
|
SetClasses(wrapper, "kg-card kg-image-card");
|
|
|
|
|
img.AddClass("kg-image");
|
|
|
|
|
|
|
|
|
|
var imgParent = img.ParentNode;
|
|
|
|
|
if (imgParent != null)
|
|
|
|
|
{
|
|
|
|
|
imgParent.InsertBefore(wrapper, img);
|
|
|
|
|
imgParent.RemoveChild(img);
|
|
|
|
|
wrapper.AppendChild(img);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// Figure ohne kg-card → kg-width-wide
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static void ConvertFigures(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var figures = htmlDoc.DocumentNode.SelectNodes("//figure");
|
|
|
|
|
if (figures == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var figure in figures.ToList())
|
|
|
|
|
{
|
|
|
|
|
if (HasClass(figure, "kg-card")) continue;
|
|
|
|
|
SetClasses(figure, "kg-width-wide kg-card-width-wide");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
// WP-Klassen mappen
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static void ConvertWpClasses(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var allNodes = htmlDoc.DocumentNode.SelectNodes("//*[contains(@class, 'wp-block')]");
|
|
|
|
|
if (allNodes == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var node in allNodes.ToList())
|
|
|
|
|
{
|
|
|
|
|
string classes = node.GetAttributeValue("class", "");
|
|
|
|
|
if (string.IsNullOrEmpty(classes)) continue;
|
|
|
|
|
|
|
|
|
|
var classList = classes.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
|
|
|
|
|
var newClasses = new List<string>();
|
|
|
|
|
bool changed = false;
|
|
|
|
|
|
|
|
|
|
foreach (string cls in classList)
|
|
|
|
|
{
|
|
|
|
|
if (WpClassMap.TryGetValue(cls, out string? ghostClass))
|
|
|
|
|
{
|
|
|
|
|
changed = true;
|
|
|
|
|
if (ghostClass != null && ghostClass.Length > 0)
|
|
|
|
|
newClasses.AddRange(ghostClass.Split(' '));
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
newClasses.Add(cls);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed)
|
|
|
|
|
{
|
|
|
|
|
string finalClasses = string.Join(" ", newClasses.Where(c => c.Length > 0).Distinct());
|
|
|
|
|
if (finalClasses.Length > 0)
|
|
|
|
|
node.SetAttributeValue("class", finalClasses);
|
|
|
|
|
else
|
|
|
|
|
node.Attributes.Remove("class");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Separator
|
|
|
|
|
var separators = htmlDoc.DocumentNode.SelectNodes("//div[contains(@class, 'wp-block-separator')]");
|
|
|
|
|
if (separators != null)
|
|
|
|
|
{
|
|
|
|
|
foreach (var sep in separators.ToList())
|
|
|
|
|
{
|
|
|
|
|
var hr = htmlDoc.CreateElement("hr");
|
|
|
|
|
foreach (var attr in sep.Attributes)
|
|
|
|
|
hr.SetAttributeValue(attr.Name, attr.Value);
|
|
|
|
|
sep.ParentNode?.ReplaceChild(hr, sep);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 08:08:51 +02:00
|
|
|
// =========================================================================
|
|
|
|
|
// Externe Links mit target="_blank" versehen
|
|
|
|
|
// =========================================================================
|
|
|
|
|
private static void AddTargetBlankToExternalLinks(HtmlDocument htmlDoc)
|
|
|
|
|
{
|
|
|
|
|
var anchors = htmlDoc.DocumentNode.SelectNodes("//a[@href]");
|
|
|
|
|
if (anchors == null) return;
|
|
|
|
|
|
|
|
|
|
foreach (var anchor in anchors.ToList())
|
|
|
|
|
{
|
|
|
|
|
string href = anchor.GetAttributeValue("href", "").Trim();
|
|
|
|
|
if (string.IsNullOrEmpty(href)) continue;
|
|
|
|
|
|
|
|
|
|
// Nur absolute externe URLs (http/https)
|
|
|
|
|
if (href.StartsWith("http://") || href.StartsWith("https://"))
|
|
|
|
|
{
|
|
|
|
|
anchor.SetAttributeValue("target", "_blank");
|
|
|
|
|
anchor.SetAttributeValue("rel", "noopener noreferrer");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 08:31:23 +02:00
|
|
|
private static bool HasClass(HtmlNode node, string className)
|
|
|
|
|
{
|
|
|
|
|
string classes = node.GetAttributeValue("class", "");
|
|
|
|
|
return classes.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
|
|
|
|
.Any(c => c.Equals(className, StringComparison.OrdinalIgnoreCase));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void SetClasses(HtmlNode node, string classes)
|
|
|
|
|
{
|
|
|
|
|
node.SetAttributeValue("class", classes);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|