From 20c49bb1c5abd8fa74b9ea8787fc94d6dc8764b0 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 14 Oct 2026 19:12:29 +0200 Subject: [PATCH] - bevor mergen der UIs --- .gitignore | 13 + CSharpGhostImporter/CommentBlockBuilder.cs | 140 ++ CSharpGhostImporter/Converter.cs | 909 ++++++++++--- CSharpGhostImporter/GhostApi.cs | 204 ++- CSharpGhostImporter/GhostDto.cs | 1 + CSharpGhostImporter/GhostExporter.cs | 34 +- CSharpGhostImporter/HtmlConverter.cs | 511 ++++--- CSharpGhostImporter/ImageManager.cs | 100 +- CSharpGhostImporter/InternalLinkManager.cs | 181 +++ CSharpGhostImporter/LinkChecker.cs | 130 ++ CSharpGhostImporter/MediaManager.cs | 240 ++++ CSharpGhostImporter/PostChecks.cs | 410 ++++++ CSharpGhostImporter/PostFetcher.cs | 130 +- CSharpGhostImporter/SlugHelper.cs | 59 + CSharpGhostImporter/VideoLinkCleaner.cs | 261 ++++ CSharpGhostImporter/WPDto.cs | 40 + .../Components/Pages/Images.razor | 161 ++- GhostImageManager/Models/GhostImage.cs | 8 + GhostImageManager/Models/GhostPost.cs | 3 + GhostImageManager/Program.cs | 14 + GhostImageManager/Services/GhostApiService.cs | 98 +- .../Services/ImageAnalyzerService.cs | 182 ++- .../Services/ImageDeletionService.cs | 42 +- GhostTheme/fernweh.zip | Bin 0 -> 17449 bytes GhostTheme/fernweh/assets/css/screen.css | 1178 ++++++++++++++++ GhostTheme/fernweh/assets/js/main.js | 78 ++ GhostTheme/fernweh/author.hbs | 24 + GhostTheme/fernweh/default.hbs | 35 + GhostTheme/fernweh/error.hbs | 9 + GhostTheme/fernweh/index.hbs | 60 + GhostTheme/fernweh/locales/de.json | 31 + GhostTheme/fernweh/locales/en.json | 31 + GhostTheme/fernweh/package.json | 78 ++ GhostTheme/fernweh/page.hbs | 35 + GhostTheme/fernweh/partials/pagination.hbs | 16 + GhostTheme/fernweh/partials/post-card.hbs | 26 + GhostTheme/fernweh/partials/sidebar-posts.hbs | 16 + GhostTheme/fernweh/partials/sidebar.hbs | 54 + GhostTheme/fernweh/partials/site-footer.hbs | 16 + GhostTheme/fernweh/partials/site-header.hbs | 129 ++ GhostTheme/fernweh/partials/tag-section.hbs | 14 + GhostTheme/fernweh/post.hbs | 90 ++ GhostTheme/fernweh/tag.hbs | 21 + GhostTheme/fernweh2-preview.html | 51 + GhostTheme/fernweh2.zip | Bin 0 -> 19107 bytes GhostTheme/fernweh2/assets/css/screen.css | 1208 +++++++++++++++++ GhostTheme/fernweh2/assets/js/main.js | 78 ++ GhostTheme/fernweh2/author.hbs | 24 + GhostTheme/fernweh2/default.hbs | 35 + GhostTheme/fernweh2/error.hbs | 9 + GhostTheme/fernweh2/index.hbs | 81 ++ GhostTheme/fernweh2/locales/de.json | 32 + GhostTheme/fernweh2/locales/en.json | 32 + GhostTheme/fernweh2/package.json | 75 + GhostTheme/fernweh2/page.hbs | 35 + GhostTheme/fernweh2/partials/about.hbs | 12 + GhostTheme/fernweh2/partials/destinations.hbs | 37 + GhostTheme/fernweh2/partials/pagination.hbs | 16 + GhostTheme/fernweh2/partials/post-card.hbs | 24 + GhostTheme/fernweh2/partials/site-footer.hbs | 18 + GhostTheme/fernweh2/partials/site-header.hbs | 96 ++ GhostTheme/fernweh2/partials/social-links.hbs | 38 + GhostTheme/fernweh2/partials/tag-section.hbs | 14 + GhostTheme/fernweh2/post.hbs | 90 ++ GhostTheme/fernweh2/tag.hbs | 29 + GhostTheme/unterwegs.zip | Bin 0 -> 18652 bytes GhostTheme/unterwegs/assets/css/screen.css | 511 +++++++ GhostTheme/unterwegs/assets/js/main.js | 78 ++ GhostTheme/unterwegs/author.hbs | 23 + GhostTheme/unterwegs/default.hbs | 35 + GhostTheme/unterwegs/error.hbs | 9 + GhostTheme/unterwegs/index.hbs | 17 + GhostTheme/unterwegs/locales/de.json | 20 + GhostTheme/unterwegs/locales/en.json | 20 + GhostTheme/unterwegs/package.json | 62 + GhostTheme/unterwegs/page.hbs | 32 + GhostTheme/unterwegs/partials/pagination.hbs | 16 + GhostTheme/unterwegs/partials/post-card.hbs | 38 + GhostTheme/unterwegs/partials/site-footer.hbs | 20 + GhostTheme/unterwegs/partials/site-header.hbs | 121 ++ GhostTheme/unterwegs/post.hbs | 74 + GhostTheme/unterwegs/tag.hbs | 21 + WPPlugin/rbrExportRestApi.php | 10 +- Wp2GhostUI/Components/App.razor | 28 + Wp2GhostUI/Components/Pages/Home.razor | 562 ++++++++ Wp2GhostUI/Components/RenderModes.cs | 10 + Wp2GhostUI/Components/Routes.razor | 5 + Wp2GhostUI/Components/_Imports.razor | 10 + Wp2GhostUI/ImporterSession.cs | 190 +++ Wp2GhostUI/LinkSearchService.cs | 133 ++ Wp2GhostUI/PostCatalog.cs | 154 +++ Wp2GhostUI/Program.cs | 40 + Wp2GhostUI/PromptOptions.cs | 75 + Wp2GhostUI/Properties/launchSettings.json | 12 + Wp2GhostUI/Wp2GhostUI.csproj | 15 + Wp2GhostUI/Wp2GhostUI.slnx | 3 + Wp2GhostUI/appsettings.json | 4 + Wp2GhostUI/wwwroot/app.css | 279 ++++ 98 files changed, 9879 insertions(+), 594 deletions(-) create mode 100644 CSharpGhostImporter/CommentBlockBuilder.cs create mode 100644 CSharpGhostImporter/InternalLinkManager.cs create mode 100644 CSharpGhostImporter/LinkChecker.cs create mode 100644 CSharpGhostImporter/MediaManager.cs create mode 100644 CSharpGhostImporter/PostChecks.cs create mode 100644 CSharpGhostImporter/SlugHelper.cs create mode 100644 CSharpGhostImporter/VideoLinkCleaner.cs create mode 100644 GhostTheme/fernweh.zip create mode 100644 GhostTheme/fernweh/assets/css/screen.css create mode 100644 GhostTheme/fernweh/assets/js/main.js create mode 100644 GhostTheme/fernweh/author.hbs create mode 100644 GhostTheme/fernweh/default.hbs create mode 100644 GhostTheme/fernweh/error.hbs create mode 100644 GhostTheme/fernweh/index.hbs create mode 100644 GhostTheme/fernweh/locales/de.json create mode 100644 GhostTheme/fernweh/locales/en.json create mode 100644 GhostTheme/fernweh/package.json create mode 100644 GhostTheme/fernweh/page.hbs create mode 100644 GhostTheme/fernweh/partials/pagination.hbs create mode 100644 GhostTheme/fernweh/partials/post-card.hbs create mode 100644 GhostTheme/fernweh/partials/sidebar-posts.hbs create mode 100644 GhostTheme/fernweh/partials/sidebar.hbs create mode 100644 GhostTheme/fernweh/partials/site-footer.hbs create mode 100644 GhostTheme/fernweh/partials/site-header.hbs create mode 100644 GhostTheme/fernweh/partials/tag-section.hbs create mode 100644 GhostTheme/fernweh/post.hbs create mode 100644 GhostTheme/fernweh/tag.hbs create mode 100644 GhostTheme/fernweh2-preview.html create mode 100644 GhostTheme/fernweh2.zip create mode 100644 GhostTheme/fernweh2/assets/css/screen.css create mode 100644 GhostTheme/fernweh2/assets/js/main.js create mode 100644 GhostTheme/fernweh2/author.hbs create mode 100644 GhostTheme/fernweh2/default.hbs create mode 100644 GhostTheme/fernweh2/error.hbs create mode 100644 GhostTheme/fernweh2/index.hbs create mode 100644 GhostTheme/fernweh2/locales/de.json create mode 100644 GhostTheme/fernweh2/locales/en.json create mode 100644 GhostTheme/fernweh2/package.json create mode 100644 GhostTheme/fernweh2/page.hbs create mode 100644 GhostTheme/fernweh2/partials/about.hbs create mode 100644 GhostTheme/fernweh2/partials/destinations.hbs create mode 100644 GhostTheme/fernweh2/partials/pagination.hbs create mode 100644 GhostTheme/fernweh2/partials/post-card.hbs create mode 100644 GhostTheme/fernweh2/partials/site-footer.hbs create mode 100644 GhostTheme/fernweh2/partials/site-header.hbs create mode 100644 GhostTheme/fernweh2/partials/social-links.hbs create mode 100644 GhostTheme/fernweh2/partials/tag-section.hbs create mode 100644 GhostTheme/fernweh2/post.hbs create mode 100644 GhostTheme/fernweh2/tag.hbs create mode 100644 GhostTheme/unterwegs.zip create mode 100644 GhostTheme/unterwegs/assets/css/screen.css create mode 100644 GhostTheme/unterwegs/assets/js/main.js create mode 100644 GhostTheme/unterwegs/author.hbs create mode 100644 GhostTheme/unterwegs/default.hbs create mode 100644 GhostTheme/unterwegs/error.hbs create mode 100644 GhostTheme/unterwegs/index.hbs create mode 100644 GhostTheme/unterwegs/locales/de.json create mode 100644 GhostTheme/unterwegs/locales/en.json create mode 100644 GhostTheme/unterwegs/package.json create mode 100644 GhostTheme/unterwegs/page.hbs create mode 100644 GhostTheme/unterwegs/partials/pagination.hbs create mode 100644 GhostTheme/unterwegs/partials/post-card.hbs create mode 100644 GhostTheme/unterwegs/partials/site-footer.hbs create mode 100644 GhostTheme/unterwegs/partials/site-header.hbs create mode 100644 GhostTheme/unterwegs/post.hbs create mode 100644 GhostTheme/unterwegs/tag.hbs create mode 100644 Wp2GhostUI/Components/App.razor create mode 100644 Wp2GhostUI/Components/Pages/Home.razor create mode 100644 Wp2GhostUI/Components/RenderModes.cs create mode 100644 Wp2GhostUI/Components/Routes.razor create mode 100644 Wp2GhostUI/Components/_Imports.razor create mode 100644 Wp2GhostUI/ImporterSession.cs create mode 100644 Wp2GhostUI/LinkSearchService.cs create mode 100644 Wp2GhostUI/PostCatalog.cs create mode 100644 Wp2GhostUI/Program.cs create mode 100644 Wp2GhostUI/PromptOptions.cs create mode 100644 Wp2GhostUI/Properties/launchSettings.json create mode 100644 Wp2GhostUI/Wp2GhostUI.csproj create mode 100644 Wp2GhostUI/Wp2GhostUI.slnx create mode 100644 Wp2GhostUI/appsettings.json create mode 100644 Wp2GhostUI/wwwroot/app.css diff --git a/.gitignore b/.gitignore index 8234f1a..3b19443 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,16 @@ /images/size/w1000/2026/09/20222424_25.jpg /images/size/w2000/2026/09/20222424_25.jpg /images/size/w600/2026/09/20222424_25.jpg +/Wp2GhostUI/bin/* +/Wp2GhostUI/obj/* + +/Wp2GhostUI/.vs/ProjectEvaluation/wp2ghostui.metadata.v10.bin +/Wp2GhostUI/.vs/ProjectEvaluation/wp2ghostui.projects.v10.bin +/Wp2GhostUI/.vs/ProjectEvaluation/wp2ghostui.strings.v10.bin +/Wp2GhostUI/.vs/Wp2GhostUI.slnx/config/applicationhost.config +/Wp2GhostUI/.vs/Wp2GhostUI.slnx/FileContentIndex/40e405e5-3c47-4edc-97d5-e8275cf9e7f1.vsidx +/Wp2GhostUI/.vs/Wp2GhostUI.slnx/FileContentIndex/d82e15b7-7808-418b-9912-cb7d1ef677b8.vsidx +/Wp2GhostUI/.vs/Wp2GhostUI.slnx/v18/.futdcache.v2 +/Wp2GhostUI/.vs/Wp2GhostUI.slnx/v18/.suo +/Wp2GhostUI/.vs/Wp2GhostUI.slnx/v18/DocumentLayout.json +/GhostImageManager/logs/ghost-image-manager-20261011.log diff --git a/CSharpGhostImporter/CommentBlockBuilder.cs b/CSharpGhostImporter/CommentBlockBuilder.cs new file mode 100644 index 0000000..f438b7a --- /dev/null +++ b/CSharpGhostImporter/CommentBlockBuilder.cs @@ -0,0 +1,140 @@ +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", "
    ")}

    ")); + } + } +} diff --git a/CSharpGhostImporter/Converter.cs b/CSharpGhostImporter/Converter.cs index 7d6a959..feddadc 100644 --- a/CSharpGhostImporter/Converter.cs +++ b/CSharpGhostImporter/Converter.cs @@ -5,30 +5,113 @@ using System.Net.Http; using HtmlAgilityPack; using wp2ghost; -class Program -{ - private static string WpBaseUrl = "https://deine-domain.com"; - internal static string OutputZipPath = "ghost_import.zip"; - private static int MaxPosts = -1; - private static string ConfigPath = ""; - private static string BaseDir = ""; - private static string GhostUrl = ""; - private static string GhostAdminApiKey = ""; - private static string GhostContentApiKey = ""; +// Die Web-Oberfläche (Projekt Wp2GhostUI) ruft dieselben Abläufe auf wie das Konsolenmenü +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Wp2GhostUI")] - static async Task Main(string[] args) +/// +/// Der Importer: Einstellungen und alle Abläufe. Bedient wird er entweder über das +/// Konsolenmenü (Main) oder über die Web-Oberfläche, die dieselben Methoden aufruft. +/// Rückfragen laufen in beiden Fällen über Console.ReadLine/Write. +/// +class Importer +{ + internal static string WpBaseUrl = "https://deine-domain.com"; + internal static string OutputZipPath = "ghost_import.zip"; + internal static int MaxPosts = -1; + private static string ConfigPath = ""; + internal static string BaseDir = ""; + + /// Zugangsdaten einer Ghost-Instanz. + internal class GhostTarget { - BaseDir = Directory.GetCurrentDirectory(); + public string Name = ""; + public string Url = ""; + public string AdminApiKey = ""; + public string ContentApiKey = ""; + public bool IsConfigured => Url.Length > 0 && AdminApiKey.Length > 0; + } + + /// + /// Zwei Ghost-Instanzen, zwischen denen umgeschaltet werden kann. Gesendet, geprüft und + /// abgeglichen wird immer mit der aktiven; Slug-Zuordnungen und hochgeladene Videos führt + /// jede Instanz für sich. Das Archiv (ghostPosts/archiv/) ist für beide gemeinsam. + /// + internal static readonly GhostTarget[] GhostTargets = { new(), new() }; + internal static int ActiveGhost { get; private set; } + internal static GhostTarget Ghost => GhostTargets[ActiveGhost]; + + // Die Zugangsdaten der aktiven Instanz — so bleiben alle bisherigen Aufrufe unverändert + internal static string GhostUrl { get => Ghost.Url; set => Ghost.Url = value; } + internal static string GhostAdminApiKey { get => Ghost.AdminApiKey; set => Ghost.AdminApiKey = value; } + internal static string GhostContentApiKey { get => Ghost.ContentApiKey; set => Ghost.ContentApiKey = value; } + + /// Anzeigename einer Instanz: der vergebene Name, sonst die Adresse, sonst "Ghost 1/2". + internal static string GhostLabel(int index) + { + var target = GhostTargets[index]; + if (target.Name.Length > 0) return target.Name; + if (target.Url.Length > 0) return target.Url.Replace("https://", "").Replace("http://", "").TrimEnd('/'); + return $"Ghost {index + 1}"; + } + + /// + /// Datei im Datenordner, die nur für die aktive Instanz gilt. Die erste Instanz behält die + /// bisherigen Dateinamen, die zweite bekommt "_2" angehängt (z. B. slug_zuordnung_2.txt). + /// + internal static string InstanceFile(string fileName) => + Path.Combine(BaseDir, ActiveGhost == 0 ? fileName + : $"{Path.GetFileNameWithoutExtension(fileName)}_{ActiveGhost + 1}{Path.GetExtension(fileName)}"); + + /// Lädt die Einstellungen aus dem Datenordner und richtet alle Bausteine darauf ein. + internal static void Initialize(string baseDir) + { + BaseDir = baseDir; ConfigPath = Path.Combine(BaseDir, "config.txt"); LoadConfig(); ImageManager.Init(BaseDir); + MediaManager.Init(BaseDir); + InternalLinkManager.Init(BaseDir); PostFetcher.Init(BaseDir); GhostExporter.Init(BaseDir); LinkDecisionManager.Init(BaseDir); + ActivateGhost(); + } + + /// Speichert geänderte Einstellungen und meldet Ghost mit den neuen Zugangsdaten an. + internal static void ApplySettings() + { + SaveConfig(); + ActivateGhost(); + } + + /// Macht eine der beiden Ghost-Instanzen zur aktiven und merkt sich die Wahl. + internal static void SwitchGhost(int index) + { + if (index < 0 || index >= GhostTargets.Length || index == ActiveGhost) return; + ActiveGhost = index; + SaveConfig(); + ActivateGhost(); + Console.WriteLine($"Aktive Ghost-Instanz: {GhostLabel(index)}"); + } + + // Meldet Ghost mit den Zugangsdaten der aktiven Instanz an und lädt deren Slug-Zuordnungen + private static void ActivateGhost() + { GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); + SlugAssignments.Init(InstanceFile("slug_zuordnung.txt")); + } + + static async Task Main(string[] args) + { + Initialize(Directory.GetCurrentDirectory()); Console.OutputEncoding = System.Text.Encoding.UTF8; + // Damit Emojis/Sonderzeichen beim manuellen Editieren eingegeben werden können + if (!Console.IsInputRedirected) + { + try { Console.InputEncoding = System.Text.Encoding.Unicode; } catch { } + } Console.Title = "WP2Ghost Converter"; @@ -64,6 +147,9 @@ class Program case "8": LinkDecisionManager.ShowInfo(); break; + case "9": + ChooseGhost(); + break; case "0": ImageManager.SaveCache(); LinkDecisionManager.Save(); @@ -87,6 +173,7 @@ class Program Console.WriteLine($"Ausgabe-Datei : {OutputZipPath}"); Console.WriteLine($"Max. Beiträge/Seiten: {(MaxPosts == -1 ? "Alle" : MaxPosts.ToString())}"); Console.WriteLine($"Abgefragte Objekte : {PostFetcher.ProcessedCount}"); + Console.WriteLine($"Ghost-Instanz : {ActiveGhost + 1} – {GhostLabel(ActiveGhost)}"); Console.WriteLine($"Ghost-URL : {(string.IsNullOrEmpty(GhostUrl) ? "(nicht konfiguriert)" : GhostUrl)}"); Console.WriteLine($"Ghost-Status : {(GhostApi.IsConfigured() ? "verbunden" : "nicht konfiguriert")}"); Console.WriteLine("-----------------------------------------"); @@ -98,6 +185,7 @@ class Program Console.WriteLine(" 6 - Bilder lokal löschen"); Console.WriteLine(" 7 - Beiträge direkt an Ghost senden"); Console.WriteLine(" 8 - Gespeicherte Link-Entscheidungen anzeigen"); + Console.WriteLine(" 9 - Ghost-Instanz wechseln"); Console.WriteLine(" 0 - Beenden"); Console.WriteLine("-----------------------------------------"); Console.Write("Auswahl: "); @@ -114,42 +202,33 @@ class Program try { - var lines = File.ReadAllLines(ConfigPath); - foreach (var line in lines) + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in File.ReadAllLines(ConfigPath)) { if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; var parts = line.Split('=', 2); if (parts.Length == 2) - { - string key = parts[0].Trim(); - string value = parts[1].Trim(); - - switch (key.ToLower()) - { - case "wpbaseurl": - WpBaseUrl = value; - break; - case "outputzippath": - OutputZipPath = value; - break; - case "maxposts": - if (int.TryParse(value, out int max)) - MaxPosts = max; - break; - case "ghosturl": - GhostUrl = value; - break; - case "ghostadminapikey": - GhostAdminApiKey = value; - break; - case "ghostcontentapikey": - GhostContentApiKey = value; - break; - } - } + values[parts[0].Trim()] = parts[1].Trim(); } + + if (values.TryGetValue("wpBaseUrl", out string? wpBaseUrl)) WpBaseUrl = wpBaseUrl; + if (values.TryGetValue("outputZipPath", out string? outputZipPath)) OutputZipPath = outputZipPath; + if (values.TryGetValue("maxPosts", out string? maxPosts) && int.TryParse(maxPosts, out int max)) MaxPosts = max; + + for (int i = 0; i < GhostTargets.Length; i++) + { + string prefix = GhostConfigPrefix(i); + var target = GhostTargets[i]; + target.Name = values.GetValueOrDefault(prefix + "Name", ""); + target.Url = values.GetValueOrDefault(prefix + "Url", ""); + target.AdminApiKey = values.GetValueOrDefault(prefix + "AdminApiKey", ""); + target.ContentApiKey = values.GetValueOrDefault(prefix + "ContentApiKey", ""); + } + if (values.TryGetValue("activeGhost", out string? active) && int.TryParse(active, out int number) + && number >= 1 && number <= GhostTargets.Length) + ActiveGhost = number - 1; } catch (Exception ex) { @@ -157,6 +236,9 @@ class Program } } + // Schlüssel in config.txt: die erste Instanz behält die bisherigen (ghostUrl ...), die zweite heißt ghost2Url ... + private static string GhostConfigPrefix(int index) => index == 0 ? "ghost" : $"ghost{index + 1}"; + static void SaveConfig() { var lines = new List @@ -168,13 +250,39 @@ class Program $"outputZipPath={OutputZipPath}", $"maxPosts={MaxPosts}", "", - "# Ghost API (optional - für direkten Push)", - $"ghostUrl={GhostUrl}", - $"ghostAdminApiKey={GhostAdminApiKey}", + "# Ghost API (optional - für direkten Push). Zwei Instanzen, activeGhost wählt die aktive (1 oder 2).", + $"activeGhost={ActiveGhost + 1}", }; + for (int i = 0; i < GhostTargets.Length; i++) + { + string prefix = GhostConfigPrefix(i); + var target = GhostTargets[i]; + lines.Add(""); + lines.Add($"{prefix}Name={target.Name}"); + lines.Add($"{prefix}Url={target.Url}"); + lines.Add($"{prefix}AdminApiKey={target.AdminApiKey}"); + if (!string.IsNullOrEmpty(target.ContentApiKey)) + lines.Add($"{prefix}ContentApiKey={target.ContentApiKey}"); + } File.WriteAllLines(ConfigPath, lines); } + static void ChooseGhost() + { + Console.WriteLine(); + Console.WriteLine("--- Ghost-Instanz wechseln ---"); + for (int i = 0; i < GhostTargets.Length; i++) + { + string state = GhostTargets[i].IsConfigured ? GhostTargets[i].Url : "(nicht eingerichtet)"; + Console.WriteLine($" {i + 1} - {GhostLabel(i)}{(i == ActiveGhost ? " [aktiv]" : "")} {state}"); + } + Console.Write("Auswahl (leer = unverändert): "); + if (int.TryParse(Console.ReadLine()?.Trim(), out int number) && number >= 1 && number <= GhostTargets.Length) + SwitchGhost(number - 1); + if (!Ghost.IsConfigured) + Console.WriteLine("Diese Instanz ist noch nicht eingerichtet: bitte Option 3 (Einstellungen) nutzen."); + } + static void ConfigureSettings() { Console.WriteLine(); @@ -195,7 +303,12 @@ class Program MaxPosts = max; Console.WriteLine(); - Console.WriteLine("--- Ghost API (optional) ---"); + Console.WriteLine($"--- Ghost API (optional) – Instanz {ActiveGhost + 1}, wechseln mit Option 9 ---"); + Console.Write($"Name der Instanz [{Ghost.Name}]: "); + input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input)) + Ghost.Name = input; + Console.Write($"Ghost-URL [{GhostUrl}]: "); input = Console.ReadLine()?.Trim(); if (!string.IsNullOrEmpty(input)) @@ -211,9 +324,7 @@ class Program if (!string.IsNullOrEmpty(input)) GhostContentApiKey = input; - GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); - - SaveConfig(); + ApplySettings(); Console.WriteLine("Einstellungen gespeichert."); } @@ -244,6 +355,7 @@ class Program Console.WriteLine("========================================="); Console.WriteLine(" Ghost Admin API"); Console.WriteLine("========================================="); + Console.WriteLine($" Instanz : {ActiveGhost + 1} – {GhostLabel(ActiveGhost)}"); Console.WriteLine($" Ghost-URL : {GhostUrl}"); Console.WriteLine("-----------------------------------------"); Console.WriteLine(" 1 - Posts anzeigen (Ghost)"); @@ -254,6 +366,8 @@ class Program Console.WriteLine(" 6 - Alle lokalen Posts an Ghost senden"); Console.WriteLine(" 7 - Bilder hochladen"); Console.WriteLine(" 8 - Post aus JSON erstellen/aktualisieren"); + Console.WriteLine(" 9 - Untertag zu allen Posts mit einem bestimmten Tag hinzufügen"); + Console.WriteLine(" 10 - Posts in Ghost prüfen und korrigieren (z. B. Postdatum)"); Console.WriteLine(" 0 - Zurück zum Hauptmenü"); Console.WriteLine("-----------------------------------------"); Console.Write("Auswahl: "); @@ -277,7 +391,7 @@ class Program await GhostDeletePostAsync(); break; case "6": - await GhostApi.PushAllPostsAsync(html => HtmlGhostConverter.Convert(html)); + await GhostApi.PushAllPostsAsync(html => InternalLinkManager.Rewrite(HtmlGhostConverter.Convert(html), WpBaseUrl, GhostUrl).Html); break; case "7": await GhostUploadImagesAsync(); @@ -285,6 +399,12 @@ class Program case "8": await GhostCreateOrUpdateFromJsonAsync(); break; + case "9": + await GhostAddTagToTaggedPostsAsync(); + break; + case "10": + await PostChecker.RunAsync(BaseDir); + break; case "0": inGhostMenu = false; break; @@ -481,43 +601,70 @@ class Program var doc = new HtmlDocument(); doc.LoadHtml(html); - var invalidLinks = new List<(string text, string href, HtmlNode node)>(); - // Alle -Tags suchen var anchors = doc.DocumentNode.SelectNodes("//a[@href]"); if (anchors == null) return html; + bool repaired = false; + var candidates = new List<(string text, string href, HtmlNode node)>(); foreach (var anchor in anchors) { string href = anchor.GetAttributeValue("href", "").Trim(); - string text = anchor.InnerText.Trim(); - if (string.IsNullOrEmpty(href)) continue; - // Prüfe, ob URL valide ist (inkl. Reichbarkeitsprüfung) - if (!await IsValidUrlAsync(href, checkReachability: true)) + // Eindeutig reparierbare Adressen (z. B. von \"...\" umschlossen) stillschweigend korrigieren + string? fixedHref = LinkChecker.TryRepair(HtmlEntity.DeEntitize(href)); + if (fixedHref != null) { - invalidLinks.Add((text, href, anchor)); + Console.WriteLine($" [Links] Adresse repariert: {fixedHref}"); + anchor.SetAttributeValue("href", fixedHref); + href = fixedHref; + repaired = true; } + + // Links auf die eigene Ghost-Seite nicht prüfen: das Ziel wird evtl. erst später importiert + if (!string.IsNullOrEmpty(GhostUrl) && href.StartsWith(GhostUrl.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) continue; + + candidates.Add((anchor.InnerText.Trim(), href, anchor)); } - if (invalidLinks.Count == 0) return html; + // Erreichbarkeit parallel prüfen (begrenzt, damit kein Server mit Anfragen überhäuft wird) + using var throttle = new SemaphoreSlim(6); + var reasons = await Task.WhenAll(candidates.Select(async c => + { + await throttle.WaitAsync(); + try { return await LinkChecker.CheckAsync(HtmlEntity.DeEntitize(c.href)); } + finally { throttle.Release(); } + })); + + var invalidLinks = new List<(string text, string href, HtmlNode node, string reason)>(); + for (int i = 0; i < candidates.Count; i++) + { + if (reasons[i] != null) + invalidLinks.Add((candidates[i].text, candidates[i].href, candidates[i].node, reasons[i]!)); + } + + if (invalidLinks.Count == 0) return repaired ? doc.DocumentNode.OuterHtml : html; // User über invaliden Links informieren und zur Korrektur auffordern Console.WriteLine($"\n {invalidLinks.Count} ungültiger Link(s) gefunden:"); for (int i = 0; i < invalidLinks.Count; i++) { - Console.WriteLine($" [{i + 1}] Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}"); + Console.WriteLine($" [{i + 1}] {invalidLinks[i].reason} | Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}"); } Console.WriteLine("\nOptionen pro Link:"); Console.WriteLine(" d = Link löschen (Text bleibt, -Tag entfernt)"); Console.WriteLine(" e = Link bearbeiten (neue URL eingeben)"); - Console.WriteLine(" Enter = Link so übernehmen (beibehalten)"); + Console.WriteLine(" k = Link so übernehmen (beibehalten)"); + Console.WriteLine(" Enter = frühere Entscheidung für diesen Link (nur wenn es eine gibt)"); // Für jeden invaliden Link die User-Entscheidung einholen - foreach (var (linkText, linkHref, anchorNode) in invalidLinks) + foreach (var (linkText, linkHref, anchorNode, linkReason) in invalidLinks) { + // Für die Web-Oberfläche: um welchen Link es bei den folgenden Rückfragen geht + LinkQuestion.Current = new LinkQuestion(linkText, HtmlEntity.DeEntitize(linkHref), linkReason); + // 1. Memory: Frühere Entscheidung suchen und vorschlagen var previous = LinkDecisionManager.GetPreviousDecision(linkText, linkHref); @@ -536,70 +683,199 @@ class Program Console.WriteLine($" [Memory] Frühere Entscheidung: {prevInfo} (vom {previous.Timestamp:dd.MM.yyyy HH:mm})"); } - // 2. Browser-Öffnen anbieten (da Link nicht erreichbar ist) + // 2. Grund nennen und Browser-Öffnen anbieten string searchQuery = Uri.EscapeDataString(linkText); string searchUrl = $"https://www.google.com/search?q={searchQuery}"; - Console.WriteLine($" [Suche] Fallstrick: Link nicht erreichbar. Browser-Suche verfügbar."); + Console.WriteLine($" Problem : {linkReason}"); - Console.Write($" Optionen: [Enter=übernehmen" + (previous != null ? $" / v=Vorschlag '{previous.Decision}' anwenden" : "") + " / d=löschen / e=bearbeiten / b=Browser öffnen]: "); - string? choice = Console.ReadLine()?.Trim()?.ToLowerInvariant(); - - // Browser öffnen - if (choice == "b") + // Gibt es schon eine Entscheidung für diesen Link, ist sie der Default (Enter). + // Ohne frühere Entscheidung MUSS der User eine treffen — Enter allein genügt dann nicht. + string options = "k=beibehalten / d=löschen / e=bearbeiten / b=Browser öffnen"; + if (previous != null) { - Console.WriteLine($" -> Öffne Browser mit Suche nach: \"{linkText}\""); - OpenBrowser(searchUrl); - // Nach dem Browser-Öffnen erneut nach der Entscheidung fragen - Console.Write($" Jetzt entscheiden? [Enter=übernehmen / d=löschen / e=bearbeiten]: "); - choice = Console.ReadLine()?.Trim()?.ToLowerInvariant(); - } - - // Memory-Vorschlag anwenden - if (choice == "v" && previous != null) - { - ApplyDecision(anchorNode, previous, linkText, linkHref, ref html, doc); - Console.WriteLine($" -> Frühere Entscheidung angewendet: {previous.Decision}"); - continue; - } - - if (choice == "d") - { - // Link löschen: -Tag entfernen, Text erhalten - var parent = anchorNode.ParentNode; - if (parent != null) + string defaultLabel = previous.Decision switch { - parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); + "delete" => "wie zuvor: löschen", + "edit" => $"wie zuvor: ersetzen durch '{previous.NewUrl}'", + _ => "wie zuvor: beibehalten" + }; + options = $"Enter={defaultLabel} / {options}"; + } + + string decision = ""; + string? newUrl = null; + while (decision == "") + { + Console.Write($" Optionen: [{options}]: "); + string? input = Console.ReadLine(); + if (input == null) + { + // Keine Eingabe möglich (stdin geschlossen): nichts ändern und nichts merken, + // damit beim nächsten Lauf erneut gefragt wird + decision = previous != null ? "previous" : "unanswered"; + break; } - Console.WriteLine($" -> Link gelöscht"); - LinkDecisionManager.SaveDecision(linkText, linkHref, "delete"); - } - else if (choice == "e") - { - // Link bearbeiten: neue URL eingeben - Console.Write(" Neue URL eingeben: "); - string? newUrl = Console.ReadLine()?.Trim(); - if (!string.IsNullOrEmpty(newUrl)) + + switch (input.Trim().ToLowerInvariant()) { - anchorNode.SetAttributeValue("href", newUrl); + case "b": + Console.WriteLine($" -> Öffne Browser mit Suche nach: \"{linkText}\""); + OpenBrowser(searchUrl); + break; + case "": + case "v": + if (previous != null) decision = "previous"; + else Console.WriteLine(" Für diesen Link gibt es noch keine Entscheidung – bitte k, d oder e wählen."); + break; + case "k": + decision = "keep"; + break; + case "d": + decision = "delete"; + break; + case "e": + Console.Write(" Neue URL eingeben: "); + newUrl = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(newUrl)) decision = "edit"; + else Console.WriteLine(" Keine URL eingegeben – bitte erneut wählen."); + break; + default: + Console.WriteLine(" Ungültige Eingabe – bitte k, d oder e wählen."); + break; + } + } + + switch (decision) + { + case "previous": + ApplyDecision(anchorNode, previous!, linkText, linkHref, ref html, doc); + Console.WriteLine($" -> Frühere Entscheidung angewendet: {previous!.Decision}"); + break; + case "delete": + // Link löschen: -Tag entfernen, Text erhalten + anchorNode.ParentNode?.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); + Console.WriteLine($" -> Link gelöscht"); + LinkDecisionManager.SaveDecision(linkText, linkHref, "delete"); + break; + case "edit": + anchorNode.SetAttributeValue("href", newUrl!); Console.WriteLine($" -> Auf '{newUrl}' aktualisiert"); LinkDecisionManager.SaveDecision(linkText, linkHref, "edit", newUrl); - } - else - { + break; + case "keep": Console.WriteLine($" -> Beibehalten"); LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); - } - } - else - { - Console.WriteLine($" -> Beibehalten"); - LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); + break; + default: + Console.WriteLine($" -> Keine Eingabe möglich, Link bleibt unverändert (wird beim nächsten Lauf erneut gefragt)"); + break; } } + LinkQuestion.Current = null; return doc.DocumentNode.OuterHtml; } + // ========================================================================= + // Problemfälle im Text: mehrere "?" hintereinander + // Entstehen, wenn Zeichen (Emojis, griechische/arabische Schrift ...) bei einer + // früheren Konvertierung verloren gingen. Pro Fundstelle entscheidet der User: + // löschen, manuell editieren oder beibehalten. Entscheidungen werden gemerkt. + // ========================================================================= + + // "??" und längere Folgen, auch über Leer-/Satzzeichen hinweg ("???? ??? ?????, ????") + private static readonly System.Text.RegularExpressions.Regex QuestionMarkRunRegex = new( + @"\?{2,}(?:[\s,.;:\-/]*\?+)*", System.Text.RegularExpressions.RegexOptions.Compiled); + + // Kennung, unter der Text-Entscheidungen im Link-Memory abgelegt werden (statt einer URL) + private const string TextProblemMarker = "text:fragezeichen"; + + private static string ReviewQuestionMarksInHtml(string html) + { + if (string.IsNullOrEmpty(html) || !QuestionMarkRunRegex.IsMatch(html)) return html; + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // Nur sichtbarer Text — keine URLs/Attribute, kein Code + var textNodes = doc.DocumentNode.SelectNodes("//text()[not(ancestor::script) and not(ancestor::style) and not(ancestor::code) and not(ancestor::pre)]"); + if (textNodes == null) return html; + + bool changed = false; + foreach (var node in textNodes.ToList()) + { + string original = node.InnerHtml; + if (!QuestionMarkRunRegex.IsMatch(original)) continue; + + string reviewed = ReviewQuestionMarksInText(original, "Text", htmlEncoded: true); + if (reviewed == original) continue; + + node.ParentNode!.ReplaceChild(doc.CreateTextNode(reviewed), node); + changed = true; + } + + return changed ? doc.DocumentNode.OuterHtml : html; + } + + private static string ReviewQuestionMarksInText(string text, string where, bool htmlEncoded = false) + { + if (string.IsNullOrEmpty(text)) return text; + + return QuestionMarkRunRegex.Replace(text, match => + { + // Umgebung der Fundstelle anzeigen, die "?"-Folge hervorgehoben + int from = Math.Max(0, match.Index - 50); + int to = Math.Min(text.Length, match.Index + match.Length + 30); + string before = text.Substring(from, match.Index - from); + string after = text.Substring(match.Index + match.Length, to - match.Index - match.Length); + string context = WebUtility.HtmlDecode($"{before}»{match.Value}«{after}").Trim(); + + var previous = LinkDecisionManager.GetPreviousDecision(context, TextProblemMarker); + if (previous != null) + { + string result = ApplyTextDecision(previous.Decision, previous.NewUrl, match.Value, htmlEncoded); + Console.WriteLine($" [Text] Frühere Entscheidung angewendet ({previous.Decision}): {context}"); + return result; + } + + Console.WriteLine(); + Console.WriteLine($" [Problemfall {where}] Mehrere '?' – vermutlich verlorene Zeichen:"); + Console.WriteLine($" {context}"); + Console.Write(" Optionen: [l=löschen / e=manuell editieren / Enter=beibehalten]: "); + string? choice = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (choice == "l") + { + LinkDecisionManager.SaveDecision(context, TextProblemMarker, "delete"); + Console.WriteLine(" -> gelöscht"); + return ApplyTextDecision("delete", null, match.Value, htmlEncoded); + } + + if (choice == "e") + { + Console.Write($" Ersatz für »{match.Value}« eingeben: "); + string? replacement = Console.ReadLine(); + if (!string.IsNullOrEmpty(replacement)) + { + LinkDecisionManager.SaveDecision(context, TextProblemMarker, "edit", replacement); + Console.WriteLine($" -> ersetzt durch '{replacement}'"); + return ApplyTextDecision("edit", replacement, match.Value, htmlEncoded); + } + } + + LinkDecisionManager.SaveDecision(context, TextProblemMarker, "keep"); + Console.WriteLine(" -> beibehalten"); + return match.Value; + }); + } + + private static string ApplyTextDecision(string decision, string? replacement, string original, bool htmlEncoded) => decision switch + { + "delete" => "", + "edit" when !string.IsNullOrEmpty(replacement) => htmlEncoded ? WebUtility.HtmlEncode(replacement) : replacement, + _ => original + }; + // ========================================================================= // Hilfsmethoden für Link-Entscheidungen // ========================================================================= @@ -644,60 +920,96 @@ class Program } } - private static async Task IsValidUrlAsync(string url, bool checkReachability = false) + /// + /// Hängt an alle Posts in Ghost, die einen bestimmten Tag tragen, einen weiteren Tag an + /// (z. B. alle "Zypern"-Posts zusätzlich nach "Unsere Reisen"). Der neue Tag wird angelegt, + /// falls es ihn noch nicht gibt, und steht hinter den vorhandenen Tags — der primäre Tag + /// eines Posts ändert sich nicht. + /// + static async Task GhostAddTagToTaggedPostsAsync() { - if (string.IsNullOrWhiteSpace(url)) return false; - - // Relative URLs (beginnen mit / oder sind Pfade) sind für interne Links gültig - if (url.StartsWith("/") || !url.Contains(":")) - { - if (checkReachability) { /* relative links are considered valid regardless */ } - return true; - } - - // Prüfe auf gültiges URI-Format mit http/https Schemes try { - var uri = new Uri(url, UriKind.Absolute); - if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) - return false; - - // Optionale Reichbarkeitsprüfung - if (checkReachability) + var allTags = await GhostApi.BrowseTagsWithCountsAsync(); + if (allTags.Count == 0) { - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; - try - { - var request = new HttpRequestMessage(HttpMethod.Head, uri); - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - return (int)response.StatusCode < 400; // 2xx oder 3xx = erreichbar - } - catch (HttpRequestException) - { - // Verbindungsfehler, DNS-Failure, Timeout etc. - return false; - } - catch (TaskCanceledException) - { - // Timeout - return false; - } + Console.WriteLine(" In Ghost gibt es noch keine Tags."); + return; } - return true; + Console.WriteLine($" {allTags.Count} Tags in Ghost (Anzahl Posts):"); + foreach (var tag in allTags) + Console.WriteLine($" {tag.Name} ({tag.Count?.Posts ?? 0})"); + + Console.Write("Posts mit welchem Tag? (Name, leer = Abbruch): "); + string sourceName = Console.ReadLine()?.Trim() ?? ""; + if (sourceName.Length == 0) return; + + var sourceTag = allTags.FirstOrDefault(t => string.Equals(t.Name, sourceName, StringComparison.OrdinalIgnoreCase)) + ?? allTags.FirstOrDefault(t => string.Equals(t.Slug, sourceName, StringComparison.OrdinalIgnoreCase)); + if (sourceTag?.Slug == null) + { + Console.WriteLine($" Tag '{sourceName}' gibt es in Ghost nicht."); + return; + } + + Console.Write($"Welcher Untertag soll zu allen '{sourceTag.Name}'-Posts hinzugefügt werden? (leer = Abbruch): "); + string newName = Console.ReadLine()?.Trim() ?? ""; + if (newName.Length == 0) return; + if (string.Equals(newName, sourceTag.Name, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(" Das ist derselbe Tag."); + return; + } + + var posts = await GhostApi.BrowsePostsByTagAsync(sourceTag.Slug); + var missing = posts.Where(p => !(p.Tags ?? new()).Any(t => string.Equals(t.Name, newName, StringComparison.OrdinalIgnoreCase))).ToList(); + Console.WriteLine($" {posts.Count} Post(s) mit '{sourceTag.Name}', davon {posts.Count - missing.Count} bereits mit '{newName}'."); + if (missing.Count == 0) + { + Console.WriteLine(" Nichts zu tun."); + return; + } + + bool exists = allTags.Any(t => string.Equals(t.Name, newName, StringComparison.OrdinalIgnoreCase)); + Console.Write($" '{newName}'{(exists ? "" : " (wird neu angelegt)")} zu {missing.Count} Post(s) hinzufügen? (j/n): "); + if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") return; + + var newTag = (await GhostApi.EnsureTagsAsync(new List { new WpCategory { Name = newName } })).FirstOrDefault(); + if (newTag?.Id == null) + { + Console.WriteLine($" [Fehler] Tag '{newName}' konnte nicht angelegt werden."); + return; + } + + int done = 0; + foreach (var post in missing) + { + Console.Write($" [{done + 1}/{missing.Count}] {post.Title}..."); + // Vorhandene Tags in ihrer Reihenfolge behalten, den neuen hinten anhängen + var tagIds = (post.Tags ?? new()).Where(t => t.Id != null).Select(t => t.Id!).Append(newTag.Id).Distinct(); + bool ok = await GhostApi.SetPostTagsAsync(post.Id!, post.UpdatedAt ?? "", tagIds); + Console.WriteLine(ok ? " OK" : " FEHLER"); + if (!ok) + { + Console.WriteLine($" ABBRUCH wegen eines Fehlers. {done} von {missing.Count} Post(s) geändert."); + return; + } + done++; + } + Console.WriteLine($" '{newTag.Name}' zu {done} Post(s) hinzugefügt."); } - catch + catch (Exception ex) { - return false; + Console.WriteLine($" [Fehler] {ex.Message}"); } } - // Synchronen Wrapper für die bestehende Validierung - private static bool IsValidUrl(string url) - { - return IsValidUrlAsync(url, false).Result; - } - + /// + /// Sendet einen Post oder einen Bereich von Posts (Von–Bis) aus ghostPosts/ an Ghost. + /// Beim ersten Fehler wird abgebrochen. Danach können die erfolgreich gesendeten Posts + /// archiviert werden (ghostPosts/archiv/), damit sie nicht mehr in der Auswahlliste stehen. + /// static async Task GhostCreateOrUpdateFromJsonAsync() { string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); @@ -707,56 +1019,190 @@ class Program return; } - var jsonFiles = Directory.GetFiles(ghostPostsDir, "*.json"); - if (jsonFiles.Length == 0) + // Nach ID sortiert (numerisch), damit Von–Bis der angezeigten Reihenfolge entspricht + var available = Directory.GetFiles(ghostPostsDir, "*.json") + .Select(f => (Path: f, Name: Path.GetFileNameWithoutExtension(f))) + .Where(f => int.TryParse(f.Name, out _)) + .Select(f => (f.Path, Id: int.Parse(f.Name))) + .OrderBy(f => f.Id) + .ToList(); + if (available.Count == 0) { Console.WriteLine("Keine JSON-Dateien in ghostPosts/ vorhanden."); return; } - Console.WriteLine($" {jsonFiles.Length} verfügbare Posts:"); - foreach (var f in jsonFiles) + Console.WriteLine($" {available.Count} verfügbare Posts:"); + foreach (var f in available) { - string id = Path.GetFileNameWithoutExtension(f); - Console.Write($" {id} - "); + Console.Write($" {f.Id} - "); try { - var preview = JsonSerializer.Deserialize(File.ReadAllText(f)); + var preview = JsonSerializer.Deserialize(File.ReadAllText(f.Path)); Console.WriteLine(preview?.Title ?? "(kein Titel)"); } catch { Console.WriteLine("(Fehler beim Lesen)"); } } - Console.Write("Ghost-ID des Posts: "); - string? ghostId = Console.ReadLine()?.Trim(); - if (string.IsNullOrEmpty(ghostId)) return; + Console.Write("Von ID (leer = Abbruch): "); + if (!int.TryParse(Console.ReadLine()?.Trim(), out int fromId)) return; - string jsonPath = Path.Combine(ghostPostsDir, $"{ghostId}.json"); - if (!File.Exists(jsonPath)) + Console.Write($"Bis ID (leer = nur {fromId}): "); + string? toInput = Console.ReadLine()?.Trim(); + int toId = fromId; + if (!string.IsNullOrEmpty(toInput) && !int.TryParse(toInput, out toId)) { - Console.WriteLine($"Datei {jsonPath} nicht gefunden."); + Console.WriteLine("Ungültige Bis-ID."); + return; + } + if (toId < fromId) (fromId, toId) = (toId, fromId); + + var selected = available.Where(f => f.Id >= fromId && f.Id <= toId).ToList(); + if (selected.Count == 0) + { + Console.WriteLine($"Keine Posts im Bereich {fromId}–{toId} gefunden."); return; } - var postFile = JsonSerializer.Deserialize(File.ReadAllText(jsonPath)); + if (selected.Count > 1) + { + Console.Write($" {selected.Count} Posts ({selected[0].Id}–{selected[^1].Id}) an Ghost senden? (j/n): "); + if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") return; + } + + await SendPostsAsync(selected); + } + + /// + /// Sendet die übergebenen Posts der Reihe nach an die aktive Ghost-Instanz, bricht beim ersten + /// Fehler ab und bietet danach an, die erfolgreich gesendeten zu archivieren. + /// + internal static async Task SendPostsAsync(List<(string Path, int Id)> selected) + { + Console.WriteLine($"Ziel: {GhostLabel(ActiveGhost)} ({GhostUrl})"); + string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); + var succeeded = new List<(string Path, int Id)>(); + (string Path, int Id)? failed = null; + for (int i = 0; i < selected.Count; i++) + { + Console.WriteLine(); + Console.WriteLine($"=== [{i + 1}/{selected.Count}] Post {selected[i].Id} ==="); + + bool ok; + try + { + ok = await ImportPostFromJsonAsync(selected[i].Path); + } + catch (Exception ex) + { + Console.WriteLine($" [Fehler] {ex.Message}"); + ok = false; + } + + if (!ok) + { + failed = selected[i]; + break; + } + succeeded.Add(selected[i]); + } + + Console.WriteLine(); + Console.WriteLine("========================================="); + Console.WriteLine($" Erfolgreich: {succeeded.Count} von {selected.Count}"); + if (failed.HasValue) + { + int notProcessed = selected.Count - succeeded.Count - 1; + Console.WriteLine($" ABBRUCH bei Post {failed.Value.Id} wegen eines Fehlers."); + if (notProcessed > 0) + Console.WriteLine($" Nicht mehr verarbeitet: {notProcessed} Post(s) ab ID {selected[succeeded.Count + 1].Id}"); + } + Console.WriteLine("========================================="); + + if (succeeded.Count == 0) return; + + Console.Write($" {succeeded.Count} erfolgreich gesendete(n) Post(s) archivieren? (j/n): "); + if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") return; + + // Archivierte Dateien liegen in einem Unterordner und erscheinen nicht mehr in der Auswahlliste + string archiveDir = Path.Combine(ghostPostsDir, "archiv"); + Directory.CreateDirectory(archiveDir); + foreach (var post in succeeded) + File.Move(post.Path, Path.Combine(archiveDir, Path.GetFileName(post.Path)), overwrite: true); + Console.WriteLine($" {succeeded.Count} Post(s) nach ghostPosts/archiv/ verschoben."); + } + + /// + /// Sendet einen einzelnen Post an Ghost. Liefert false, sobald etwas schiefgeht — + /// ein Post mit fehlenden Bildern oder Videos wird dann gar nicht erst angelegt. + /// + // Zuletzt eingegebene Untertags — bei einem Von–Bis-Export gehören oft mehrere Posts zum selben Thema + private static string _lastSubTags = ""; + + private static async Task ImportPostFromJsonAsync(string jsonPath) + { + GhostPostFile? postFile; + try + { + postFile = JsonSerializer.Deserialize(File.ReadAllText(jsonPath)); + } + catch (Exception ex) + { + Console.WriteLine($" [Fehler] JSON konnte nicht gelesen werden: {ex.Message}"); + return false; + } if (postFile == null) { - Console.WriteLine("JSON konnte nicht gelesen werden."); - return; + Console.WriteLine(" [Fehler] JSON konnte nicht gelesen werden."); + return false; } + // Auch ältere JSON-Dateien enthalten noch prozentkodierte WordPress-Slugs + postFile.Slug = SlugHelper.Clean(postFile.Slug, $"post-{postFile.WpId}"); + + // Die Kategorie "Allgemein" wird nie übernommen — weder als Tag noch als Schlagwort + postFile.Categories = postFile.Categories.Where(c => !CategoryRules.IsIgnored(c)).ToList(); + Console.WriteLine($" Post: {postFile.Title}"); Console.WriteLine($" Slug: {postFile.Slug}"); + + // Immer das Veröffentlichungsdatum des WordPress-Beitrags verwenden. Ohne Datum würde + // Ghost den Zeitpunkt des Imports eintragen — dann lieber abbrechen. + string? publishedAt = DateHelper.ToGhostUtc(postFile.PublishedAt ?? postFile.CreatedAt); + if (publishedAt == null) + { + Console.WriteLine(" [Fehler] Kein Veröffentlichungsdatum im JSON – Beitrag bitte neu abrufen."); + return false; + } + Console.WriteLine($" Datum: {DateTimeOffset.Parse(publishedAt).ToLocalTime():dd.MM.yyyy HH:mm}"); + Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); Console.WriteLine($" Schlagwörter: {postFile.Tags.Count}"); - Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); + Console.WriteLine($" Kommentare: {CommentBlockBuilder.CountRealComments(postFile.Comments)} (ohne Pingbacks/Reaktionen)"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); // 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen var ghostUrlMap = new Dictionary(); + int errors = 0; string html = postFile.Html ?? ""; + + // WordPress-Kommentare als Block ans Beitragsende (bei älteren JSON-Dateien fehlt er noch) + html = CommentBlockBuilder.Append(html, postFile.Comments); + + // Links auf eigene WordPress-Beiträge auf die Ghost-Adresse umschreiben + var internalLinks = InternalLinkManager.Rewrite(html, WpBaseUrl, GhostUrl); + html = internalLinks.Html; + if (internalLinks.Replaced > 0) + Console.WriteLine($" [Links] {internalLinks.Replaced} interne(r) Link(s) auf Ghost umgeschrieben"); + foreach (string open in internalLinks.Unresolved) + Console.WriteLine($" [Links] Interner Link, Ziel (noch) nicht abgerufen – bleibt auf WordPress: {open}"); + html = await ValidateAndFixLinksAsync(html); + // Problemfälle im Text melden: mehrere "?" aus fehlerhafter Zeichen-Konvertierung + postFile.Title = ReviewQuestionMarksInText(postFile.Title ?? "", "Titel"); + html = ReviewQuestionMarksInHtml(html); + if (postFile.ImageFiles.Count > 0) { Console.WriteLine($" [Bilder] Lade {postFile.ImageFiles.Count} Bilder hoch..."); @@ -765,7 +1211,8 @@ class Program string localPath = Path.Combine(BaseDir, "images", relPath); if (!File.Exists(localPath)) { - Console.WriteLine($" [Übersprungen] {relPath} (nicht lokal)"); + Console.WriteLine($" [Fehler] {relPath} (nicht lokal)"); + errors++; continue; } Console.Write($" [Upload] {Path.GetFileName(localPath)}..."); @@ -782,7 +1229,47 @@ class Program ghostUrlMap[placeholder2] = url; } else + { Console.WriteLine(" FEHLER"); + errors++; + } + } + } + + // 1b. Videos hochladen + foreach (string relPath in postFile.MediaFiles) + { + string placeholder = MediaManager.GhostMediaPrefix + relPath; + string localPath = MediaManager.GetLocalPath(relPath); + + // In diese Instanz bereits hochgeladen und dort noch vorhanden → nicht erneut übertragen + string? url = MediaManager.GetUploadedUrl(relPath, GhostUrl); + if (url != null && await GhostApi.ImageExistsAsync(url)) + { + Console.WriteLine($" [Video] {Path.GetFileName(localPath)} existiert bereits in Ghost"); + ghostUrlMap[placeholder] = url; + continue; + } + + if (!File.Exists(localPath)) + { + Console.WriteLine($" [Fehler] Video {relPath} (nicht lokal)"); + errors++; + continue; + } + + Console.Write($" [Video-Upload] {Path.GetFileName(localPath)} ({new FileInfo(localPath).Length / 1048576.0:0.0} MB)..."); + url = await GhostApi.UploadMediaAsync(localPath); + if (url != null) + { + Console.WriteLine($" -> {url}"); + MediaManager.SetUploadedUrl(relPath, url); + ghostUrlMap[placeholder] = url; + } + else + { + Console.WriteLine(" FEHLER"); + errors++; } } @@ -799,6 +1286,13 @@ class Program Console.WriteLine(featureImageUrl != null ? $" OK" : " FEHLER"); if (featureImageUrl != null) ghostUrlMap[postFile.FeatureImage] = featureImageUrl; + else + errors++; + } + else + { + Console.WriteLine($" [Fehler] Featured Image nicht lokal: {postFile.FeatureImage}"); + errors++; } } @@ -806,11 +1300,18 @@ class Program foreach (var kvp in ghostUrlMap) html = html.Replace(kvp.Key, kvp.Value); - // Falls noch Rest Platzhalter → Warnung + // Übrig gebliebene Platzhalter wären in Ghost kaputte Bilder/Videos if (html.Contains("__GHOST_URL__")) { int remaining = System.Text.RegularExpressions.Regex.Matches(html, "__GHOST_URL__").Count; - Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML"); + Console.WriteLine($" [Fehler] {remaining} nicht ersetzte __GHOST_URL__-Platzhalter im HTML"); + errors++; + } + + if (errors > 0) + { + Console.WriteLine($" [Fehler] {errors} Problem(e) bei Bildern/Videos – der Post wird nicht an Ghost gesendet."); + return false; } // Bilder, die noch von WordPress geladen würden → Post neu abrufen (Hauptmenü 5, dann 1) @@ -856,20 +1357,55 @@ class Program allTagSources.AddRange(postFile.Categories); } + // Untertags: zusätzliche Tags, die der User vor dem Export vergibt. Sie stehen hinter den + // Kategorien (sind also nie der primäre Tag, solange es eine Kategorie gibt) und werden + // in Ghost angelegt, falls es sie noch nicht gibt. + Console.WriteLine(); + if (allTagSources.Count > 0) + Console.WriteLine($" [Tags] Bisher: {string.Join(", ", allTagSources.Select(c => WebUtility.HtmlDecode(c.Name ?? "")))}"); + string again = _lastSubTags.Length > 0 ? $", + = wie zuletzt: {_lastSubTags}" : ""; + Console.Write($" Untertag hinzufügen (mehrere mit Komma, leer = keiner{again}): "); + string subTagInput = Console.ReadLine()?.Trim() ?? ""; + if (subTagInput == "+") subTagInput = _lastSubTags; + if (subTagInput.Length > 0) + { + _lastSubTags = subTagInput; + var known = allTagSources.Select(c => WebUtility.HtmlDecode(c.Name ?? "").Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (string name in subTagInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (known.Add(name)) + allTagSources.Add(new WpCategory { Name = name }); + } + } + if (allTagSources.Count > 0) { - Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Kategorien..."); + Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Tag(s)..."); tags = await GhostApi.EnsureTagsAsync(allTagSources); + + // Jede Kategorie mit Namen muss als Tag vorhanden sein + int expectedTags = allTagSources + .Select(c => WebUtility.HtmlDecode(c.Name ?? "").Trim().ToLowerInvariant()) + .Where(n => n.Length > 0).Distinct().Count(); + if (tags.Count < expectedTags) + { + Console.WriteLine($" [Fehler] Nur {tags.Count} von {expectedTags} Tags konnten angelegt/zugeordnet werden."); + return false; + } } // 4b. Schlagwörter (WP-Tags + SEO-Keywords) ohne Nachfrage als var keywords = postFile.Tags.Select(t => t.Name ?? "") .Concat((postFile.Keywords ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)) + .Where(k => !CategoryRules.IsIgnored(k)) .ToList(); // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert + // Hat der User diesem WordPress-Beitrag einen abweichenden Ghost-Slug zugeordnet + // (Prüffunktion), wird dort gesucht — sonst entstünde ein zweiter Post. + string lookupSlug = SlugAssignments.GetGhostSlug(postFile.WpId) ?? postFile.Slug ?? ""; Console.Write(" Prüfe Ghost..."); - var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{postFile.Slug}", 1); + var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{lookupSlug}", 1); var existing = existingPosts.FirstOrDefault(); GhostAdminPost? result = null; @@ -882,7 +1418,7 @@ class Program result = await GhostApi.UpdatePostExtendedAsync( existing.Id!, postFile.Title ?? "", html, existing.UpdatedAt ?? "", postFile.Status ?? "published", - featureImageUrl, tags, head); + featureImageUrl, tags, head, publishedAt); Console.WriteLine(result != null ? " OK" : " FEHLER"); } else @@ -894,33 +1430,10 @@ class Program result = await GhostApi.CreatePostExtendedAsync( postFile.Title ?? "", html, postFile.Status ?? "published", postFile.Slug, - featureImageUrl, tags, head); + featureImageUrl, tags, head, publishedAt); Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER"); } - // 4. Kommentare als Members verarbeiten - if (postFile.Comments.Count > 0) - { - Console.WriteLine(); - Console.Write($" {postFile.Comments.Count} Kommentare verarbeiten? (j/n): "); - string? commentConfirm = Console.ReadLine()?.Trim(); - if (commentConfirm?.ToLower() == "j") - { - foreach (var comment in postFile.Comments) - { - if (string.IsNullOrEmpty(comment.AuthorEmail)) continue; - var member = await GhostApi.EnsureMemberAsync(comment.AuthorName ?? "Anonymous", comment.AuthorEmail); - if (member != null) - { - Console.WriteLine($" [Kommentar] {comment.AuthorName}: {comment.Content?.Substring(0, Math.Min(50, comment.Content?.Length ?? 0))}..."); - } - } - } - } - + return result != null; } } - - - - diff --git a/CSharpGhostImporter/GhostApi.cs b/CSharpGhostImporter/GhostApi.cs index 92ebecb..3941451 100644 --- a/CSharpGhostImporter/GhostApi.cs +++ b/CSharpGhostImporter/GhostApi.cs @@ -86,13 +86,15 @@ namespace wp2ghost return result?.Posts?.FirstOrDefault(); } - public static async Task CreatePostAsync(string title, string html, string status = "published", string? slug = null) + public static async Task CreatePostAsync(string title, string html, string status = "published", string? slug = null, string? publishedAt = null) { var post = new GhostAdminPostEdit { Title = title, Html = html, - Status = status + Status = status, + PublishedAt = publishedAt, + CreatedAt = publishedAt }; if (slug != null) post.Slug = slug; @@ -113,7 +115,7 @@ namespace wp2ghost return result?.Posts?.FirstOrDefault(); } - public static async Task UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published") + public static async Task UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published", string? publishedAt = null) { var post = new GhostAdminPostEdit { @@ -121,7 +123,8 @@ namespace wp2ghost Title = title, Html = html, Status = status, - UpdatedAt = updatedAt + UpdatedAt = updatedAt, + PublishedAt = publishedAt }; var body = new { posts = new[] { post } }; @@ -149,6 +152,102 @@ namespace wp2ghost } // ===================== Tags ===================== + /// Alle Tags mit der Anzahl ihrer Posts. + public static async Task> BrowseTagsWithCountsAsync() + { + const int limit = 100; + var all = new List(); + for (int page = 1; ; page++) + { + var request = await CreateRequestAsync(HttpMethod.Get, $"tags/?limit={limit}&page={page}&include=count.posts&order=name%20asc"); + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var tags = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync())?.Tags ?? new(); + all.AddRange(tags); + if (tags.Count < limit) break; + } + return all; + } + + /// Alle Posts in Ghost (jeden Status), nur mit den Kopfdaten — ohne Inhalt. + public static async Task> BrowseAllPostsAsync() + { + const int limit = 100; + var all = new List(); + for (int page = 1; ; page++) + { + var request = await CreateRequestAsync(HttpMethod.Get, $"posts/?limit={limit}&page={page}&fields=id,title,slug,status,created_at,updated_at,published_at,feature_image&order=published_at%20desc"); + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var posts = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync())?.Posts ?? new(); + all.AddRange(posts); + if (posts.Count < limit) break; + } + return all; + } + + /// + /// Ändert einzelne Felder eines Posts. Gesendet wird nur, was in "edit" gesetzt ist + /// (plus updated_at als Schutz vor gleichzeitigen Änderungen); alles andere bleibt unberührt. + /// + public static async Task UpdatePostFieldsAsync(string postId, GhostAdminPostEdit edit) + { + var body = new { posts = new[] { edit } }; + var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/"); + request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}"); + return false; + } + return true; + } + + /// Alle Posts (jeden Status) mit einem bestimmten Tag, jeweils mit ihrer vollständigen Tag-Liste. + public static async Task> BrowsePostsByTagAsync(string tagSlug) + { + const int limit = 50; + var all = new List(); + for (int page = 1; ; page++) + { + string filter = Uri.EscapeDataString($"tag:{tagSlug}"); + var request = await CreateRequestAsync(HttpMethod.Get, $"posts/?limit={limit}&page={page}&filter={filter}&include=tags&order=published_at%20asc"); + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var posts = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync())?.Posts ?? new(); + all.AddRange(posts); + if (posts.Count < limit) break; + } + return all; + } + + /// + /// Setzt die Tags eines Posts. Ghost ersetzt die Zuordnung komplett, daher muss die + /// vollständige Liste in der gewünschten Reihenfolge übergeben werden. Inhalt, Titel usw. + /// bleiben unverändert. + /// + public static async Task SetPostTagsAsync(string postId, string updatedAt, IEnumerable tagIds) + { + var post = new GhostAdminPostEdit + { + UpdatedAt = updatedAt, + Tags = tagIds.Select(id => new GhostAdminTagRef { Id = id }).ToList() + }; + var body = new { posts = new[] { post } }; + var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/"); + request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}"); + return false; + } + return true; + } + public static async Task> BrowseTagsAsync(string filter = "", int limit = 100) { // Browse-Endpunkte sind paginiert -> alle Seiten laden @@ -209,7 +308,9 @@ namespace wp2ghost else { Console.Write($" [Tag] Erstelle '{name}'..."); - var newTag = await CreateTagAsync(name, cat.Slug); + // Leerer Fallback → Ghost bildet den Slug selbst aus dem Namen + string tagSlug = SlugHelper.Clean(cat.Slug, ""); + var newTag = await CreateTagAsync(name, tagSlug.Length > 0 ? tagSlug : null); if (newTag != null) { Console.WriteLine(" OK"); @@ -297,7 +398,7 @@ namespace wp2ghost return rest.Length > 0 ? rest + "\n" + meta : meta; } - public static async Task CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List tags, string? codeinjectionHead = null) + public static async Task CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List tags, string? codeinjectionHead = null, string? publishedAt = null) { var post = new GhostAdminPostEdit { @@ -305,7 +406,9 @@ namespace wp2ghost Html = html, Status = status, FeatureImage = featureImage, - CodeinjectionHead = codeinjectionHead + CodeinjectionHead = codeinjectionHead, + PublishedAt = publishedAt, + CreatedAt = publishedAt }; if (slug != null) post.Slug = slug; if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList(); @@ -325,7 +428,7 @@ namespace wp2ghost return result?.Posts?.FirstOrDefault(); } - public static async Task UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List tags, string? codeinjectionHead = null) + public static async Task UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List tags, string? codeinjectionHead = null, string? publishedAt = null) { var post = new GhostAdminPostEdit { @@ -335,7 +438,8 @@ namespace wp2ghost Status = status, UpdatedAt = updatedAt, FeatureImage = featureImage, - CodeinjectionHead = codeinjectionHead + CodeinjectionHead = codeinjectionHead, + PublishedAt = publishedAt }; if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList(); @@ -419,6 +523,58 @@ namespace wp2ghost catch { return false; } } + // Video-Uploads dauern länger als der Standard-Timeout von 100 Sekunden + private static readonly HttpClient mediaUploadClient = new() { Timeout = TimeSpan.FromMinutes(60) }; + + /// Lädt ein Video über die Media-API hoch und liefert seine Adresse in Ghost. + public static async Task UploadMediaAsync(string localPath) + { + if (!File.Exists(localPath)) + { + Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}"); + return null; + } + + string fileName = Path.GetFileName(localPath); + string mimeType = Path.GetExtension(fileName).ToLowerInvariant() switch + { + ".mp4" or ".m4v" => "video/mp4", + ".webm" => "video/webm", + ".ogv" => "video/ogg", + ".mov" => "video/quicktime", + _ => "application/octet-stream" + }; + + try + { + var request = await CreateRequestAsync(HttpMethod.Post, "media/upload/"); + await using var fileStream = File.OpenRead(localPath); + var fileContent = new StreamContent(fileStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + var formContent = new MultipartFormDataContent(); + formContent.Add(fileContent, "file", fileName); + request.Content = formContent; + + var response = await mediaUploadClient.SendAsync(request); + string body = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + string hint = response.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge + ? " (Datei größer als das Upload-Limit des Ghost-Servers)" : ""; + Console.WriteLine($" [Ghost-Fehler] Video-Upload fehlgeschlagen: {response.StatusCode}{hint}: {body}"); + return null; + } + + using var json = JsonDocument.Parse(body); + return json.RootElement.GetProperty("media")[0].GetProperty("url").GetString(); + } + catch (Exception ex) + { + Console.WriteLine($" [Ghost-Fehler] Video-Upload fehlgeschlagen: {ex.Message}"); + return null; + } + } + public static async Task UploadImageIfNewAsync(string localPath, DateTime? postDate = null) { if (!File.Exists(localPath)) @@ -488,7 +644,7 @@ namespace wp2ghost } Console.Write($" [Update] {title}..."); - var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? ""); + var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? "", publishedAt: DateHelper.ToGhostUtc(localPost.PublishedAt)); if (updatedPost != null) { Console.WriteLine(" OK"); @@ -500,7 +656,7 @@ namespace wp2ghost else { Console.Write($" [Neu] {title}..."); - var newPost = await CreatePostAsync(title, html, "published", slug); + var newPost = await CreatePostAsync(title, html, "published", slug, DateHelper.ToGhostUtc(localPost.PublishedAt)); if (newPost != null) { Console.WriteLine(" OK"); @@ -560,6 +716,13 @@ namespace wp2ghost [JsonPropertyName("codeinjection_head")] public string? CodeinjectionHead { get; set; } + + [JsonPropertyName("published_at")] + public string? PublishedAt { get; set; } + + // Nur gefüllt, wenn mit include=tags abgefragt + [JsonPropertyName("tags")] + public List? Tags { get; set; } } public class GhostAdminPostEdit @@ -596,6 +759,15 @@ namespace wp2ghost [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Tags { get; set; } + // Veröffentlichungs-/Erstelldatum (UTC). Ohne Angabe setzt Ghost den Zeitpunkt des Imports. + [JsonPropertyName("published_at")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PublishedAt { get; set; } + + [JsonPropertyName("created_at")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CreatedAt { get; set; } + [JsonPropertyName("meta_title")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? MetaTitle { get; set; } @@ -641,6 +813,16 @@ namespace wp2ghost [JsonPropertyName("slug")] public string? Slug { get; set; } + + // Nur gefüllt, wenn mit include=count.posts abgefragt + [JsonPropertyName("count")] + public GhostAdminTagCount? Count { get; set; } + } + + public class GhostAdminTagCount + { + [JsonPropertyName("posts")] + public int Posts { get; set; } } public class GhostAdminTagEdit diff --git a/CSharpGhostImporter/GhostDto.cs b/CSharpGhostImporter/GhostDto.cs index 30aeb39..8dc8d35 100644 --- a/CSharpGhostImporter/GhostDto.cs +++ b/CSharpGhostImporter/GhostDto.cs @@ -36,6 +36,7 @@ namespace wp2ghost [JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("comments")] public List Comments { get; set; } = new(); [JsonPropertyName("image_files")] public List ImageFiles { get; set; } = new(); + [JsonPropertyName("media_files")] public List MediaFiles { get; set; } = new(); [JsonPropertyName("html")] public string? Html { get; set; } } } diff --git a/CSharpGhostImporter/GhostExporter.cs b/CSharpGhostImporter/GhostExporter.cs index 686cd5d..1b35422 100644 --- a/CSharpGhostImporter/GhostExporter.cs +++ b/CSharpGhostImporter/GhostExporter.cs @@ -32,15 +32,30 @@ namespace wp2ghost return; } - string outputPath = Path.Combine(BaseDir, Program.OutputZipPath); + string outputPath = Path.Combine(BaseDir, Importer.OutputZipPath); try { //PostFetcher.SaveAllMarkdownFiles(); + // Links zwischen den eigenen Beiträgen auf Ghost umschreiben (Kopie, Original bleibt unverändert) + int internalLinks = 0; + var exportPosts = posts.Select(p => + { + var rewritten = InternalLinkManager.Rewrite(p.Html ?? "", Importer.WpBaseUrl, "__GHOST_URL__"); + internalLinks += rewritten.Replaced; + return new GhostPost + { + Id = p.Id, Title = p.Title, Slug = p.Slug, Html = rewritten.Html, Markdown = p.Markdown, + FeatureImage = p.FeatureImage, Status = p.Status, CreatedAt = p.CreatedAt, PublishedAt = p.PublishedAt + }; + }).ToList(); + if (internalLinks > 0) + Console.WriteLine($" {internalLinks} interne(r) Link(s) auf Ghost umgeschrieben"); + string jsonString = JsonSerializer.Serialize(new GhostRoot { - Db = new List { new GhostDb { Data = new GhostData { Posts = posts.ToList() } } } + Db = new List { new GhostDb { Data = new GhostData { Posts = exportPosts } } } }, new JsonSerializerOptions { WriteIndented = true, @@ -87,6 +102,20 @@ namespace wp2ghost } } } + + // Videos unkomprimiert ablegen (MP4 lässt sich nicht weiter packen) + foreach (var file in MediaManager.CachedFiles) + { + if (File.Exists(file.LocalPath)) + { + var fileEntry = archive.CreateEntry($"content/media/{file.RelativePath}", CompressionLevel.NoCompression); + using (var entryStream = fileEntry.Open()) + using (var fileStream = File.OpenRead(file.LocalPath)) + { + await fileStream.CopyToAsync(entryStream); + } + } + } } Console.WriteLine(); @@ -94,6 +123,7 @@ namespace wp2ghost Console.WriteLine($" Ghost ZIP erstellt!"); Console.WriteLine($" Beiträge: {posts.Count}"); Console.WriteLine($" Bilder: {ImageManager.CachedFiles.Count} Dateien"); + Console.WriteLine($" Videos: {MediaManager.CachedFiles.Count} Dateien"); Console.WriteLine($" Ausgabe: {outputPath}"); Console.WriteLine("========================================="); } diff --git a/CSharpGhostImporter/HtmlConverter.cs b/CSharpGhostImporter/HtmlConverter.cs index 75cb189..7581946 100644 --- a/CSharpGhostImporter/HtmlConverter.cs +++ b/CSharpGhostImporter/HtmlConverter.cs @@ -80,30 +80,123 @@ namespace wp2ghost { "wp-block-jetpack-story", "kg-card kg-gallery-card" }, }; - public static string Convert(string html) + // 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? _imageSizeResolver; + private static int _ghostGalleryCount; + private static int _skippedGalleryImages; + private static int _captionedGalleryImages; + + public static string Convert(string html, Func? imageSizeResolver = null) { if (string.IsNullOrWhiteSpace(html)) return string.Empty; + _imageSizeResolver = imageSizeResolver; + _ghostGalleryCount = 0; + _skippedGalleryImages = 0; + _captionedGalleryImages = 0; + var htmlDoc = new HtmlDocument(); htmlDoc.LoadHtml(html); + RemoveJunkCaptions(htmlDoc); + + int brokenImages = RemoveBrokenImages(htmlDoc); + if (brokenImages > 0) + Console.WriteLine($" [Warnung] {brokenImages} Bild(er) mit blob:-Quelle entfernt (schon in WordPress defekt)"); + int totalGalleries = 0; totalGalleries += ConvertClassicGalleries(htmlDoc); - totalGalleries += ConvertGutenbergGalleries(htmlDoc); - totalGalleries += ConvertJetpackGalleries(htmlDoc); - totalGalleries += ConvertCoBlocksGalleries(htmlDoc); + + // 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')]"); if (totalGalleries > 0) - Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert"); + { + 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"); ConvertSingleImages(htmlDoc); ConvertFigures(htmlDoc); ConvertWpClasses(htmlDoc); AddTargetBlankToExternalLinks(htmlDoc); + _imageSizeResolver = null; return htmlDoc.DocumentNode.OuterHtml; } + // ========================================================================= + // 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(); + } + // ========================================================================= // Gallery-Typ 1: WordPress Classic Gallery // HTML: +
    + + +
    -
    - - -
    +
    @@ -70,7 +71,7 @@ else }
    -
    +
    @@ -99,10 +100,10 @@ else
    - @if (SelectedOrphanedImages.Any()) + @if (SelectedDeletableImages.Any()) { - } } @@ -114,6 +115,11 @@ else
    @_errorMessage
    } + @if (!string.IsNullOrEmpty(_warningMessage)) + { +
    @_warningMessage
    + } + @if (!string.IsNullOrEmpty(_successMessage)) {
    @_successMessage
    @@ -128,20 +134,13 @@ else +
    Gefiltert: @_filteredImages.Count von @_allImages.Count - @if (!string.IsNullOrWhiteSpace(_authorFilter) || !string.IsNullOrWhiteSpace(_themeFilter) || !string.IsNullOrWhiteSpace(_categoryFilter)) - { - - @if (!string.IsNullOrWhiteSpace(_authorFilter)): @:Autor - @if (!string.IsNullOrWhiteSpace(_themeFilter)): @:Thema - @if (!string.IsNullOrWhiteSpace(_categoryFilter)): @:Kategorie - - }
    @@ -150,6 +149,12 @@ else
    +
    +
    + + +
    +
    @@ -157,7 +162,7 @@ else {
    - @if (image.IsOrphaned) + @if (image.CanDelete) {
    @@ -195,7 +200,14 @@ else }

    - @if (image.IsOrphaned) + @if (image.IsOriginal) + { +
    + Originaldatei von @image.OriginalOf + @(image.IsOrphaned ? "(Bild wird nicht verwendet)" : "(Bild wird verwendet)") +
    + } + else if (image.IsOrphaned) {
    ⚠️ Verwaist (nicht in Posts verwendet) @@ -210,7 +222,7 @@ else {
  1. - @(post.IsFeatureImage ? "Feature" : "Content") + @(post.IsFeatureImage ? "Feature" : post.IsInContent ? "Content" : "Website") @post.Title
  2. @@ -230,7 +242,7 @@ else - @if (image.IsOrphaned) + @if (image.CanDelete) {