From 48d2de05edf618148332fa03f1dd28d1a009329f Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 20 Sep 2026 08:31:23 +0200 Subject: [PATCH] first commit --- .gitignore | 3 + AGENTS.md | 3 + .../CSharpGhostImporter.csproj | 14 + CSharpGhostImporter/Converter.cs | 635 +++++++++++++++++ CSharpGhostImporter/GhostApi.cs | 662 ++++++++++++++++++ CSharpGhostImporter/GhostDto.cs | 39 ++ CSharpGhostImporter/GhostExporter.cs | 107 +++ CSharpGhostImporter/HtmlConverter.cs | 570 +++++++++++++++ .../HtmlToMarkdownConverter.cs | 143 ++++ CSharpGhostImporter/ImageManager.cs | 221 ++++++ CSharpGhostImporter/PostFetcher.cs | 487 +++++++++++++ CSharpGhostImporter/WPDto.cs | 48 ++ CSharpGhostImporter/config.txt | 6 + CSharpGhostImporter/imagecache.txt | 3 + CSharpGhostImporter/processed_posts.txt | 3 + CSharpGhostImporter/test_gallery.html | 105 +++ CSharpGhostImporter/wp2ghost.slnx | 3 + WPPlugin/rbrExportRestApi.php | 396 +++++++++++ WPPlugin/rbrExportRestApi.zip | Bin 0 -> 3284 bytes 19 files changed, 3448 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CSharpGhostImporter/CSharpGhostImporter.csproj create mode 100644 CSharpGhostImporter/Converter.cs create mode 100644 CSharpGhostImporter/GhostApi.cs create mode 100644 CSharpGhostImporter/GhostDto.cs create mode 100644 CSharpGhostImporter/GhostExporter.cs create mode 100644 CSharpGhostImporter/HtmlConverter.cs create mode 100644 CSharpGhostImporter/HtmlToMarkdownConverter.cs create mode 100644 CSharpGhostImporter/ImageManager.cs create mode 100644 CSharpGhostImporter/PostFetcher.cs create mode 100644 CSharpGhostImporter/WPDto.cs create mode 100644 CSharpGhostImporter/config.txt create mode 100644 CSharpGhostImporter/imagecache.txt create mode 100644 CSharpGhostImporter/processed_posts.txt create mode 100644 CSharpGhostImporter/test_gallery.html create mode 100644 CSharpGhostImporter/wp2ghost.slnx create mode 100644 WPPlugin/rbrExportRestApi.php create mode 100644 WPPlugin/rbrExportRestApi.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5b1290 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/CSharpGhostImporter/.vs/* +/CSharpGhostImporter/bin/* +/CSharpGhostImporter/obj/* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4809b19 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +Du arbeitest an einem Ghost-Projekt. Nutze bei Fragen zur API oder Dokumentation +immer das integrierte `webfetch`-Tool, um die aktuelle Referenz abzurufen: +- Hauptdokumentation: https://docs.ghost.org/llms-full.txt \ No newline at end of file diff --git a/CSharpGhostImporter/CSharpGhostImporter.csproj b/CSharpGhostImporter/CSharpGhostImporter.csproj new file mode 100644 index 0000000..83bd37e --- /dev/null +++ b/CSharpGhostImporter/CSharpGhostImporter.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + enable + enable + CSharpGhostImporter + + + + + + + diff --git a/CSharpGhostImporter/Converter.cs b/CSharpGhostImporter/Converter.cs new file mode 100644 index 0000000..fd07da8 --- /dev/null +++ b/CSharpGhostImporter/Converter.cs @@ -0,0 +1,635 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +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 = ""; + + static async Task Main(string[] args) + { + BaseDir = Directory.GetCurrentDirectory(); + ConfigPath = Path.Combine(BaseDir, "config.txt"); + + LoadConfig(); + ImageManager.Init(BaseDir); + PostFetcher.Init(BaseDir); + GhostExporter.Init(BaseDir); + GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); + + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.Title = "WP2Ghost Converter"; + + + bool running = true; + while (running) + { + ShowMenu(); + string? choice = Console.ReadLine()?.Trim(); + + switch (choice) + { + case "1": + await PostFetcher.FetchFromWordPressAsync(WpBaseUrl, MaxPosts); + break; + case "2": + await GhostExporter.CreateZipAsync(); + break; + case "3": + ConfigureSettings(); + break; + case "4": + ShowCacheInfo(); + break; + case "5": + PostFetcher.ClearProcessedPosts(); + break; + case "6": + ImageManager.ClearAll(); + break; + case "7": + await GhostMenuAsync(); + break; + case "0": + ImageManager.SaveCache(); + running = false; + Console.WriteLine("Programm beendet. Auf Wiedersehen!"); + break; + default: + Console.WriteLine("Ungültige Auswahl. Bitte erneut eingeben."); + break; + } + } + } + + static void ShowMenu() + { + Console.WriteLine(); + Console.WriteLine("========================================="); + Console.WriteLine(" WP2Ghost - WordPress to Ghost"); + Console.WriteLine("========================================="); + Console.WriteLine($"WordPress-URL : {WpBaseUrl}"); + 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-URL : {(string.IsNullOrEmpty(GhostUrl) ? "(nicht konfiguriert)" : GhostUrl)}"); + Console.WriteLine($"Ghost-Status : {(GhostApi.IsConfigured() ? "verbunden" : "nicht konfiguriert")}"); + Console.WriteLine("-----------------------------------------"); + Console.WriteLine(" 1 - Von WordPress abrufen (Posts & Pages)"); + Console.WriteLine(" 2 - Ghost ZIP erstellen"); + Console.WriteLine(" 3 - Einstellungen konfigurieren"); + Console.WriteLine(" 4 - Cache-Informationen anzeigen"); + Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen"); + Console.WriteLine(" 6 - Bilder lokal löschen"); + Console.WriteLine(" 7 - Beiträge direkt an Ghost senden"); + Console.WriteLine(" 0 - Beenden"); + Console.WriteLine("-----------------------------------------"); + Console.Write("Auswahl: "); + } + + static void LoadConfig() + { + if (!File.Exists(ConfigPath)) + { + SaveConfig(); + Console.WriteLine($"Neue Konfigurationsdatei erstellt: {ConfigPath}"); + return; + } + + try + { + var lines = File.ReadAllLines(ConfigPath); + foreach (var line in lines) + { + 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; + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Fehler beim Laden der Konfiguration: {ex.Message}"); + } + } + + static void SaveConfig() + { + var lines = new List + { + "# WP2Ghost Konfiguration", + "# Ändere die Werte und starte das Programm neu", + "", + $"wpBaseUrl={WpBaseUrl}", + $"outputZipPath={OutputZipPath}", + $"maxPosts={MaxPosts}", + "", + "# Ghost API (optional - für direkten Push)", + $"ghostUrl={GhostUrl}", + $"ghostAdminApiKey={GhostAdminApiKey}", + }; + File.WriteAllLines(ConfigPath, lines); + } + + static void ConfigureSettings() + { + Console.WriteLine(); + Console.WriteLine("--- Einstellungen konfigurieren ---"); + Console.Write($"WordPress-URL [{WpBaseUrl}]: "); + string? input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input)) + WpBaseUrl = input; + + Console.Write($"Ausgabe-Datei [{OutputZipPath}]: "); + input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input)) + OutputZipPath = input; + + Console.Write($"Maximale Anzahl Posts (leer = alle) [{(MaxPosts == -1 ? "alle" : MaxPosts.ToString())}]: "); + input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input) && int.TryParse(input, out int max)) + MaxPosts = max; + + Console.WriteLine(); + Console.WriteLine("--- Ghost API (optional) ---"); + Console.Write($"Ghost-URL [{GhostUrl}]: "); + input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input)) + GhostUrl = input; + + Console.Write($"Ghost Admin API Key [{(string.IsNullOrEmpty(GhostAdminApiKey) ? "(nicht gesetzt)" : "****")}] : "); + input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input)) + GhostAdminApiKey = input; + + Console.Write($"Ghost Content API Key [{(string.IsNullOrEmpty(GhostContentApiKey) ? "(nicht gesetzt)" : "****")}] : "); + input = Console.ReadLine()?.Trim(); + if (!string.IsNullOrEmpty(input)) + GhostContentApiKey = input; + + GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); + + SaveConfig(); + Console.WriteLine("Einstellungen gespeichert."); + } + + static void ShowCacheInfo() + { + Console.WriteLine(); + Console.WriteLine("--- Cache-Informationen ---"); + Console.WriteLine($"Abgefragte Objekte: {PostFetcher.ProcessedCount}"); + Console.WriteLine($"Ghost-Beiträge geladen: {GhostExporter.PostCount}"); + + ImageManager.ShowInfo(); + PostFetcher.ShowInfo(); + } + + static async Task GhostMenuAsync() + { + if (!GhostApi.IsConfigured()) + { + Console.WriteLine(); + Console.WriteLine("Ghost API nicht konfiguriert. Bitte zuerst Option 3 (Einstellungen) nutzen."); + return; + } + + bool inGhostMenu = true; + while (inGhostMenu) + { + Console.WriteLine(); + Console.WriteLine("========================================="); + Console.WriteLine(" Ghost Admin API"); + Console.WriteLine("========================================="); + Console.WriteLine($" Ghost-URL : {GhostUrl}"); + Console.WriteLine("-----------------------------------------"); + Console.WriteLine(" 1 - Posts anzeigen (Ghost)"); + Console.WriteLine(" 2 - Post lesen (nach ID)"); + Console.WriteLine(" 3 - Post erstellen"); + Console.WriteLine(" 4 - Post bearbeiten"); + Console.WriteLine(" 5 - Post löschen"); + Console.WriteLine(" 6 - Alle lokalen Posts an Ghost senden"); + Console.WriteLine(" 7 - Bilder hochladen"); + Console.WriteLine(" 8 - Post aus JSON erstellen/aktualisieren"); + Console.WriteLine(" 0 - Zurück zum Hauptmenü"); + Console.WriteLine("-----------------------------------------"); + Console.Write("Auswahl: "); + + string? choice = Console.ReadLine()?.Trim(); + switch (choice) + { + case "1": + await GhostBrowsePostsAsync(); + break; + case "2": + await GhostReadPostAsync(); + break; + case "3": + await GhostCreatePostAsync(); + break; + case "4": + await GhostEditPostAsync(); + break; + case "5": + await GhostDeletePostAsync(); + break; + case "6": + await GhostApi.PushAllPostsAsync(html => HtmlGhostConverter.Convert(html)); + break; + case "7": + await GhostUploadImagesAsync(); + break; + case "8": + await GhostCreateOrUpdateFromJsonAsync(); + break; + case "0": + inGhostMenu = false; + break; + default: + Console.WriteLine("Ungültige Auswahl."); + break; + } + } + } + + static async Task GhostBrowsePostsAsync() + { + Console.Write("Filter (leer = alle, z.B. status:published): "); + string? filter = Console.ReadLine()?.Trim(); + Console.Write("Anzahl (leer = 50): "); + string? limitStr = Console.ReadLine()?.Trim(); + int limit = int.TryParse(limitStr, out int l) ? l : 50; + + Console.WriteLine(); + Console.WriteLine("Lade Posts von Ghost..."); + var posts = await GhostApi.BrowsePostsAsync(filter ?? "", limit); + + if (posts.Count == 0) + { + Console.WriteLine("Keine Posts gefunden."); + return; + } + + Console.WriteLine(); + Console.WriteLine($" {"ID",-24} {"Status",-12} {"Titel"}"); + Console.WriteLine($" {new string('-', 23)} {new string('-', 11)} {new string('-', 30)}"); + foreach (var post in posts) + { + Console.WriteLine($" {post.Id,-24} {post.Status,-12} {post.Title}"); + } + Console.WriteLine(); + Console.WriteLine($" Gesamt: {posts.Count} Posts"); + } + + static async Task GhostReadPostAsync() + { + Console.Write("Post-ID: "); + string? id = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(id)) return; + + var post = await GhostApi.GetPostAsync(id); + if (post == null) + { + Console.WriteLine("Post nicht gefunden."); + return; + } + + Console.WriteLine(); + Console.WriteLine($" ID : {post.Id}"); + Console.WriteLine($" Titel : {post.Title}"); + Console.WriteLine($" Slug : {post.Slug}"); + Console.WriteLine($" Status : {post.Status}"); + Console.WriteLine($" Erstellt : {post.CreatedAt}"); + Console.WriteLine($" Aktualisiert: {post.UpdatedAt}"); + Console.WriteLine($" Feature Img : {post.FeatureImage ?? "(keine)"}"); + Console.WriteLine($" HTML ({(post.Html?.Length ?? 0)} Zeichen):"); + Console.WriteLine($" {new string('-', 50)}"); + string preview = post.Html ?? ""; + if (preview.Length > 500) preview = preview[..500] + "..."; + Console.WriteLine($" {preview}"); + } + + static async Task GhostCreatePostAsync() + { + Console.Write("Titel: "); + string? title = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(title)) return; + + Console.Write("HTML-Inhalt: "); + string? html = Console.ReadLine()?.Trim(); + + Console.Write("Status (published/draft) [published]: "); + string? status = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(status)) status = "published"; + + Console.Write("Slug (leer = automatisch): "); + string? slug = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(slug)) slug = null; + + Console.WriteLine(); + Console.Write("Erstelle Post..."); + var post = await GhostApi.CreatePostAsync(title, html ?? "", status, slug); + if (post != null) + Console.WriteLine($" OK (ID: {post.Id})"); + else + Console.WriteLine(" FEHLER"); + } + + static async Task GhostEditPostAsync() + { + Console.Write("Post-ID: "); + string? id = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(id)) return; + + var existing = await GhostApi.GetPostAsync(id); + if (existing == null) + { + Console.WriteLine("Post nicht gefunden."); + return; + } + + Console.WriteLine($" Aktueller Titel: {existing.Title}"); + Console.Write("Neuer Titel (Enter = unverändert): "); + string? title = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(title)) title = existing.Title; + + Console.Write("Neuer HTML-Inhalt (Enter = unverändert): "); + string? html = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(html)) html = existing.Html; + + Console.Write($"Neuer Status ({existing.Status}): "); + string? status = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(status)) status = existing.Status; + + Console.WriteLine(); + Console.Write("Aktualisiere Post..."); + var updated = await GhostApi.UpdatePostAsync(id, title ?? "", html ?? "", existing.UpdatedAt ?? "", status ?? "published"); + if (updated != null) + Console.WriteLine(" OK"); + else + Console.WriteLine(" FEHLER"); + } + + static async Task GhostDeletePostAsync() + { + Console.Write("Post-ID: "); + string? id = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(id)) return; + + var post = await GhostApi.GetPostAsync(id); + if (post == null) + { + Console.WriteLine("Post nicht gefunden."); + return; + } + + Console.WriteLine($" Post: {post.Title}"); + Console.Write("Wirklich löschen? (j/n): "); + string? confirm = Console.ReadLine()?.Trim(); + if (confirm?.ToLower() != "j") return; + + Console.Write("Lösche Post..."); + bool success = await GhostApi.DeletePostAsync(id); + Console.WriteLine(success ? " OK" : " FEHLER"); + } + + static async Task GhostUploadImagesAsync() + { + var cachedFiles = ImageManager.CachedFiles; + if (cachedFiles.Count == 0) + { + Console.WriteLine("Keine Bilder im Cache. Bitte zuerst Option 1 (Abruf) nutzen."); + return; + } + + Console.WriteLine($" {cachedFiles.Count} Bilder zum Hochladen vorhanden."); + Console.Write("Alle Bilder hochladen? (j/n): "); + string? confirm = Console.ReadLine()?.Trim(); + if (confirm?.ToLower() != "j") return; + + int uploaded = 0; + foreach (var file in cachedFiles) + { + Console.Write($" [Upload] {Path.GetFileName(file.LocalPath)}..."); + string? url = await GhostApi.UploadImageAsync(file.LocalPath); + if (url != null) + { + Console.WriteLine($" -> {url}"); + uploaded++; + } + else + Console.WriteLine(" FEHLER"); + } + + Console.WriteLine(); + Console.WriteLine($" Hochgeladen: {uploaded}/{cachedFiles.Count}"); + } + + static async Task GhostCreateOrUpdateFromJsonAsync() + { + string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); + if (!Directory.Exists(ghostPostsDir)) + { + Console.WriteLine("Verzeichnis 'ghostPosts' nicht gefunden."); + return; + } + + var jsonFiles = Directory.GetFiles(ghostPostsDir, "*.json"); + if (jsonFiles.Length == 0) + { + Console.WriteLine("Keine JSON-Dateien in ghostPosts/ vorhanden."); + return; + } + + Console.WriteLine($" {jsonFiles.Length} verfügbare Posts:"); + foreach (var f in jsonFiles) + { + string id = Path.GetFileNameWithoutExtension(f); + Console.Write($" {id} - "); + try + { + var preview = JsonSerializer.Deserialize(File.ReadAllText(f)); + 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; + + string jsonPath = Path.Combine(ghostPostsDir, $"{ghostId}.json"); + if (!File.Exists(jsonPath)) + { + Console.WriteLine($"Datei {jsonPath} nicht gefunden."); + return; + } + + var postFile = JsonSerializer.Deserialize(File.ReadAllText(jsonPath)); + if (postFile == null) + { + Console.WriteLine("JSON konnte nicht gelesen werden."); + return; + } + + Console.WriteLine($" Post: {postFile.Title}"); + Console.WriteLine($" Slug: {postFile.Slug}"); + Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); + Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); + Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); + + // 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen + var ghostUrlMap = new Dictionary(); + string html = postFile.Html ?? ""; + + if (postFile.ImageFiles.Count > 0) + { + Console.WriteLine($" [Bilder] Lade {postFile.ImageFiles.Count} Bilder hoch..."); + foreach (string relPath in postFile.ImageFiles) + { + string localPath = Path.Combine(BaseDir, "images", relPath); + if (!File.Exists(localPath)) + { + Console.WriteLine($" [Übersprungen] {relPath} (nicht lokal)"); + continue; + } + Console.Write($" [Upload] {Path.GetFileName(localPath)}..."); + string? url = await GhostApi.UploadImageIfNewAsync(localPath); + if (url != null) + { + Console.WriteLine($" -> {url}"); + // Backslash-normalisiert für alte JSONs + string normalized = relPath.Replace("\\", "/"); + string placeholder1 = $"__GHOST_URL__/content/images/{normalized}"; + string placeholder2 = $"__GHOST_URL__/content/images/{relPath}"; + ghostUrlMap[placeholder1] = url; + if (placeholder1 != placeholder2) + ghostUrlMap[placeholder2] = url; + } + else + Console.WriteLine(" FEHLER"); + } + } + + // 2. Featured Image hochladen (separat, da nicht in ImageFiles) + string? featureImageUrl = null; + if (!string.IsNullOrEmpty(postFile.FeatureImage)) + { + string localFeatPath = postFile.FeatureImage.Replace("__GHOST_URL__/content/images/", ""); + localFeatPath = Path.Combine(BaseDir, "images", localFeatPath); + if (File.Exists(localFeatPath)) + { + Console.Write(" [Featured Image]..."); + featureImageUrl = await GhostApi.UploadImageIfNewAsync(localFeatPath); + Console.WriteLine(featureImageUrl != null ? $" OK" : " FEHLER"); + if (featureImageUrl != null) + ghostUrlMap[postFile.FeatureImage] = featureImageUrl; + } + } + + // 3. __GHOST_URL__ im HTML durch echte URLs ersetzen + foreach (var kvp in ghostUrlMap) + html = html.Replace(kvp.Key, kvp.Value); + + // Falls noch Rest Platzhalter → Warnung + 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"); + } + + // 4. Tags erstellen/zuordnen + var tags = new List(); + if (postFile.Categories.Count > 0) + { + Console.WriteLine(" [Tags] Verarbeite Kategorien..."); + tags = await GhostApi.EnsureTagsAsync(postFile.Categories); + } + + // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert + Console.Write(" Prüfe Ghost..."); + var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{postFile.Slug}", 1); + var existing = existingPosts.FirstOrDefault(); + + GhostAdminPost? result = null; + if (existing != null) + { + Console.WriteLine($" vorhanden (ID: {existing.Id})"); + Console.Write(" Aktualisiere..."); + result = await GhostApi.UpdatePostExtendedAsync( + existing.Id!, postFile.Title ?? "", html, + existing.UpdatedAt ?? "", postFile.Status ?? "published", + featureImageUrl, tags, postFile.Keywords); + Console.WriteLine(result != null ? " OK" : " FEHLER"); + } + else + { + Console.WriteLine(" nicht vorhanden"); + Console.Write(" Erstelle neuen Post..."); + result = await GhostApi.CreatePostExtendedAsync( + postFile.Title ?? "", html, + postFile.Status ?? "published", postFile.Slug, + featureImageUrl, tags, postFile.Keywords); + 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))}..."); + } + } + } + } + + } +} + + + + diff --git a/CSharpGhostImporter/GhostApi.cs b/CSharpGhostImporter/GhostApi.cs new file mode 100644 index 0000000..0b909b2 --- /dev/null +++ b/CSharpGhostImporter/GhostApi.cs @@ -0,0 +1,662 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace wp2ghost +{ + // ============================================================================= + // GhostApi — Ghost Admin API: JWT-Auth, Bilder hochladen, Posts erstellen/bearbeiten + // ============================================================================= + public static class GhostApi + { + private static readonly HttpClient httpClient = new(); + private static string GhostUrl = ""; + private static string AdminApiKey = ""; + private static string ContentApiKey = ""; + private static int ImageContainerPathParts = 0; + + public static void Init(string ghostUrl, string adminApiKey, string contentApiKey, string imageContainerPath) + { + GhostUrl = ghostUrl.TrimEnd('/'); + AdminApiKey = adminApiKey; + ContentApiKey = contentApiKey; + ImageContainerPathParts = Path.GetFullPath(imageContainerPath).Split(Path.DirectorySeparatorChar).Length; + } + + public static bool IsConfigured() + { + return !string.IsNullOrEmpty(GhostUrl) && !string.IsNullOrEmpty(AdminApiKey); + } + + private static string GenerateToken() + { + var parts = AdminApiKey.Split(':', 2); + if (parts.Length != 2) + throw new ArgumentException("Admin API Key muss im Format {id}:{secret} sein."); + + string keyId = parts[0]; + string secret = parts[1]; + + var now = DateTimeOffset.UtcNow; + var header = new Dictionary + { + { "alg", "HS256" }, + { "typ", "JWT" }, + { "kid", keyId } + }; + var payload = new Dictionary + { + { "iat", now.ToUnixTimeSeconds() }, + { "exp", now.ToUnixTimeSeconds() + 300 }, + { "aud", "/admin/" } + }; + + byte[] secretBytes = Convert.FromHexString(secret); + return Jose.JWT.Encode(payload, secretBytes, Jose.JwsAlgorithm.HS256, extraHeaders: header); + } + + private static async Task CreateRequestAsync(HttpMethod method, string endpoint) + { + string token = GenerateToken(); + var request = new HttpRequestMessage(method, $"{GhostUrl}/ghost/api/admin/{endpoint}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Ghost", token); + request.Headers.Add("Accept-Version", "v5.0"); + return request; + } + + public static async Task> BrowsePostsAsync(string filter = "", int limit = 50) + { + string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image"; + var request = await CreateRequestAsync(HttpMethod.Get, endpoint); + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Posts ?? new List(); + } + + public static async Task GetPostAsync(string postId) + { + var request = await CreateRequestAsync(HttpMethod.Get, $"posts/{postId}/?fields=id,title,slug,status,created_at,updated_at,html,feature_image,featured"); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Posts?.FirstOrDefault(); + } + + public static async Task CreatePostAsync(string title, string html, string status = "published", string? slug = null) + { + var post = new GhostAdminPostEdit + { + Title = title, + Html = html, + Status = status + }; + if (slug != null) post.Slug = slug; + + var body = new { posts = new[] { post } }; + var request = await CreateRequestAsync(HttpMethod.Post, "posts/?source=html"); + 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 null; + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Posts?.FirstOrDefault(); + } + + public static async Task UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published") + { + var post = new GhostAdminPostEdit + { + Id = postId, + Title = title, + Html = html, + Status = status, + UpdatedAt = updatedAt + }; + + var body = new { posts = new[] { post } }; + var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/?source=html"); + 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 null; + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Posts?.FirstOrDefault(); + } + + public static async Task DeletePostAsync(string postId) + { + var request = await CreateRequestAsync(HttpMethod.Delete, $"posts/{postId}/"); + var response = await httpClient.SendAsync(request); + return response.IsSuccessStatusCode; + } + + // ===================== Tags ===================== + public static async Task> BrowseTagsAsync(string filter = "", int limit = 100) + { + string endpoint = $"tags/?limit={limit}&fields=id,name,slug"; + if (!string.IsNullOrEmpty(filter)) + endpoint += $"&filter={Uri.EscapeDataString(filter)}"; + var request = await CreateRequestAsync(HttpMethod.Get, endpoint); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return new(); + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Tags ?? new(); + } + + public static async Task CreateTagAsync(string name, string? slug = null) + { + var tag = new GhostAdminTagEdit { Name = name }; + if (slug != null) tag.Slug = slug; + var body = new { tags = new[] { tag } }; + var request = await CreateRequestAsync(HttpMethod.Post, "tags/"); + 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] Tag erstellen: {response.StatusCode}: {error}"); + return null; + } + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Tags?.FirstOrDefault(); + } + + public static async Task> EnsureTagsAsync(List categories) + { + var result = new List(); + var existingTags = await BrowseTagsAsync(); + var existingByName = existingTags.Where(t => t.Name != null).ToDictionary(t => t.Name!.ToLowerInvariant(), t => t); + + foreach (var cat in categories) + { + string name = cat.Name ?? ""; + if (string.IsNullOrEmpty(name)) continue; + if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing)) + { + result.Add(existing); + } + else + { + Console.Write($" [Tag] Erstelle '{name}'..."); + var newTag = await CreateTagAsync(name, cat.Slug); + if (newTag != null) + { + Console.WriteLine(" OK"); + result.Add(newTag); + } + else + Console.WriteLine(" FEHLER"); + } + } + return result; + } + + // ===================== Members ===================== + public static async Task> BrowseMembersAsync(string filter = "", int limit = 100) + { + string endpoint = $"members/?limit={limit}&fields=id,name,email,status"; + if (!string.IsNullOrEmpty(filter)) + endpoint += $"&filter={Uri.EscapeDataString(filter)}"; + var request = await CreateRequestAsync(HttpMethod.Get, endpoint); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return new(); + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Members ?? new(); + } + + public static async Task CreateMemberAsync(string name, string email) + { + var member = new GhostAdminMemberEdit + { + Name = name, + Email = email, + Status = "free" + }; + var body = new { members = new[] { member } }; + var request = await CreateRequestAsync(HttpMethod.Post, "members/"); + 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] Member erstellen: {response.StatusCode}: {error}"); + return null; + } + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Members?.FirstOrDefault(); + } + + public static async Task EnsureMemberAsync(string name, string email) + { + if (string.IsNullOrEmpty(email)) return null; + var existing = await BrowseMembersAsync($"email:'{email}'"); + var found = existing.FirstOrDefault(m => m.Email?.ToLowerInvariant() == email.ToLowerInvariant()); + if (found != null) return found; + + Console.Write($" [Member] Erstelle '{name}' ({email})..."); + var newMember = await CreateMemberAsync(name, email); + Console.WriteLine(newMember != null ? " OK" : " FEHLER"); + return newMember; + } + + // ===================== Posts (erweitert) ===================== + public static async Task CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List tags, string? metaKeywords = null) + { + var post = new GhostAdminPostEdit + { + Title = title, + Html = html, + Status = status, + FeatureImage = featureImage, + MetaKeywords = metaKeywords + }; + if (slug != null) post.Slug = slug; + if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList(); + + var body = new { posts = new[] { post } }; + var request = await CreateRequestAsync(HttpMethod.Post, "posts/?source=html"); + 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 null; + } + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Posts?.FirstOrDefault(); + } + + public static async Task UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List tags, string? metaKeywords = null) + { + var post = new GhostAdminPostEdit + { + Id = postId, + Title = title, + Html = html, + Status = status, + UpdatedAt = updatedAt, + FeatureImage = featureImage, + MetaKeywords = metaKeywords + }; + if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList(); + + var body = new { posts = new[] { post } }; + var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/?source=html"); + 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 null; + } + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Posts?.FirstOrDefault(); + } + + public static async Task UploadImageAsync(string localPath, DateTime? postDate = null) + { + if (!File.Exists(localPath)) + { + Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}"); + return null; + } + + var request = await CreateRequestAsync(HttpMethod.Post, "images/upload/"); + + string fileName = Path.GetFileName(localPath); + var fileBytes = await File.ReadAllBytesAsync(localPath); + var fileContent = new ByteArrayContent(fileBytes); + var pathParts = Path.GetFullPath(localPath).Split("\\"); + + string ext = Path.GetExtension(fileName).ToLowerInvariant(); + string mimeType = ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + _ => "application/octet-stream" + }; + + fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + + var formContent = new MultipartFormDataContent(); + formContent.Add(fileContent, "file", fileName); + if (pathParts.Length > ImageContainerPathParts + 1) + { + string refPath = string.Join("/", pathParts.Skip(ImageContainerPathParts).Take(pathParts.Length - ImageContainerPathParts - 1)); + if (!string.IsNullOrEmpty(refPath)) + formContent.Add(new StringContent(refPath), "ref"); + } + + request.Content = formContent; + + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + Console.WriteLine($" [Ghost-Fehler] Upload fehlgeschlagen: {response.StatusCode}: {error}"); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Images?.FirstOrDefault()?.Url; + } + + public static async Task ImageExistsAsync(string ghostUrl) + { + if (string.IsNullOrEmpty(ghostUrl) || !ghostUrl.StartsWith("http")) + return false; + try + { + var request = new HttpRequestMessage(HttpMethod.Head, ghostUrl); + var response = await httpClient.SendAsync(request); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + public static async Task UploadImageIfNewAsync(string localPath, DateTime? postDate = null) + { + if (!File.Exists(localPath)) + { + Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}"); + return null; + } + + string fileName = Path.GetFileName(localPath); + var pathParts = Path.GetFullPath(localPath).Split(Path.DirectorySeparatorChar); + string refPath = ""; + if (pathParts.Length > ImageContainerPathParts + 1) + refPath = string.Join("/", pathParts.Skip(ImageContainerPathParts).Take(pathParts.Length - ImageContainerPathParts - 1)); + + string expectedUrl = $"{GhostUrl}/content/images/{refPath.Replace("\\", "/")}/{fileName}"; + if (await ImageExistsAsync(expectedUrl)) + { + Console.Write($" [Upload] {fileName} existiert bereits, überspringen"); + return expectedUrl; + } + + return await UploadImageAsync(localPath, postDate); + } + + public static async Task PushAllPostsAsync(Func htmlTransformer) + { + if (!IsConfigured()) + { + Console.WriteLine("Ghost API nicht konfiguriert. Bitte ghostUrl und ghostAdminApiKey in config.txt setzen."); + return 0; + } + + Console.WriteLine(); + Console.WriteLine("Sende Beiträge an Ghost..."); + + var localPosts = PostFetcher.Posts; + if (localPosts.Count == 0) + { + Console.WriteLine("Keine Beiträge geladen. Bitte zuerst Option 1 ausführen."); + return 0; + } + + var ghostPosts = await BrowsePostsAsync(); + var ghostBySlug = ghostPosts.Where(p => p.Slug != null).ToDictionary(p => p.Slug!, p => p); + var ghostByTitle = ghostPosts.Where(p => p.Title != null).ToDictionary(p => p.Title!, p => p); + + int created = 0, updated = 0, skipped = 0; + + foreach (var localPost in localPosts) + { + string title = localPost.Title ?? ""; + string slug = localPost.Slug ?? ""; + string html = htmlTransformer(localPost.Html ?? ""); + + GhostAdminPost? existing = null; + if (!string.IsNullOrEmpty(slug) && ghostBySlug.ContainsKey(slug)) + existing = ghostBySlug[slug]; + else if (!string.IsNullOrEmpty(title) && ghostByTitle.ContainsKey(title)) + existing = ghostByTitle[title]; + + if (existing != null) + { + if (existing.UpdatedAt == localPost.CreatedAt) + { + skipped++; + continue; + } + + Console.Write($" [Update] {title}..."); + var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? ""); + if (updatedPost != null) + { + Console.WriteLine(" OK"); + updated++; + } + else + Console.WriteLine(" FEHLER"); + } + else + { + Console.Write($" [Neu] {title}..."); + var newPost = await CreatePostAsync(title, html, "published", slug); + if (newPost != null) + { + Console.WriteLine(" OK"); + created++; + } + else + Console.WriteLine(" FEHLER"); + } + } + + Console.WriteLine(); + Console.WriteLine("========================================="); + Console.WriteLine($" Ghost: {created} erstellt, {updated} aktualisiert, {skipped} übersprungen"); + Console.WriteLine("========================================="); + + return created + updated; + } + } + + // ============================================================================= + // Ghost Admin API DTOs + // ============================================================================= + public class GhostAdminPostsResponse + { + [JsonPropertyName("posts")] + public List? Posts { get; set; } + } + + public class GhostAdminPost + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("slug")] + public string? Slug { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("html")] + public string? Html { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("feature_image")] + public string? FeatureImage { get; set; } + + [JsonPropertyName("featured")] + public bool Featured { get; set; } + } + + public class GhostAdminPostEdit + { + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + [JsonPropertyName("slug")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Slug { get; set; } + + [JsonPropertyName("html")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Html { get; set; } + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Status { get; set; } + + [JsonPropertyName("updated_at")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("feature_image")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FeatureImage { get; set; } + + [JsonPropertyName("tags")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Tags { get; set; } + + [JsonPropertyName("meta_title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MetaTitle { get; set; } + + [JsonPropertyName("meta_description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MetaDescription { get; set; } + + [JsonPropertyName("meta_keywords")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MetaKeywords { get; set; } + } + + public class GhostImageUploadResponse + { + [JsonPropertyName("images")] + public List? Images { get; set; } + } + + public class GhostImageUploadResult + { + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("ref")] + public string? Ref { get; set; } + } + + // ===================== Tag DTOs ===================== + public class GhostAdminTagsResponse + { + [JsonPropertyName("tags")] + public List? Tags { get; set; } + } + + public class GhostAdminTag + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("slug")] + public string? Slug { get; set; } + } + + public class GhostAdminTagEdit + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("slug")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Slug { get; set; } + } + + public class GhostAdminTagRef + { + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + } + + // ===================== Member DTOs ===================== + public class GhostAdminMembersResponse + { + [JsonPropertyName("members")] + public List? Members { get; set; } + } + + public class GhostAdminMember + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + } + + public class GhostAdminMemberEdit + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + } +} diff --git a/CSharpGhostImporter/GhostDto.cs b/CSharpGhostImporter/GhostDto.cs new file mode 100644 index 0000000..283717b --- /dev/null +++ b/CSharpGhostImporter/GhostDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace wp2ghost +{ + public class GhostRoot { [JsonPropertyName("db")] public List Db { get; set; } = new() { new GhostDb() }; } + public class GhostDb { [JsonPropertyName("data")] public GhostData Data { get; set; } = new(); } + public class GhostData { [JsonPropertyName("posts")] public List Posts { get; set; } = new(); } + + public class GhostPost + { + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("title")] public string? Title { get; set; } + [JsonPropertyName("slug")] public string? Slug { get; set; } + [JsonPropertyName("html")] public string? Html { get; set; } + [JsonPropertyName("markdown")] public string? Markdown { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } + [JsonPropertyName("created_at")] public string? CreatedAt { get; set; } + [JsonPropertyName("published_at")] public string? PublishedAt { get; set; } + } + + public class GhostPostFile + { + [JsonPropertyName("wp_id")] public int WpId { get; set; } + [JsonPropertyName("wp_slug")] public string? WpSlug { get; set; } + [JsonPropertyName("ghost_id")] public string? GhostId { get; set; } + [JsonPropertyName("title")] public string? Title { get; set; } + [JsonPropertyName("slug")] public string? Slug { get; set; } + [JsonPropertyName("excerpt")] public string? Excerpt { get; set; } + [JsonPropertyName("status")] public string? Status { get; set; } + [JsonPropertyName("created_at")] public string? CreatedAt { get; set; } + [JsonPropertyName("published_at")] public string? PublishedAt { get; set; } + [JsonPropertyName("feature_image")] public string? FeatureImage { get; set; } + [JsonPropertyName("categories")] public List Categories { get; set; } = new(); + [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("html")] public string? Html { get; set; } + } +} diff --git a/CSharpGhostImporter/GhostExporter.cs b/CSharpGhostImporter/GhostExporter.cs new file mode 100644 index 0000000..686cd5d --- /dev/null +++ b/CSharpGhostImporter/GhostExporter.cs @@ -0,0 +1,107 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace wp2ghost +{ + // ============================================================================= + // GhostExporter — Ghost ZIP erstellen + // ============================================================================= + static class GhostExporter + { + private static string BaseDir = ""; + private static string MarkdownDir = ""; + + public static int PostCount => PostFetcher.PostCount; + + public static void Init(string baseDir) + { + BaseDir = baseDir; + MarkdownDir = Path.Combine(baseDir, "markdown"); + } + + public static async Task CreateZipAsync() + { + Console.WriteLine(); + Console.WriteLine("Erstelle Ghost ZIP-Datei..."); + + var posts = PostFetcher.Posts; + if (posts.Count == 0) + { + Console.WriteLine("Keine Beiträge geladen. Bitte zuerst Option 1 ausführen."); + return; + } + + string outputPath = Path.Combine(BaseDir, Program.OutputZipPath); + + try + { + //PostFetcher.SaveAllMarkdownFiles(); + + string jsonString = JsonSerializer.Serialize(new GhostRoot + { + Db = new List { new GhostDb { Data = new GhostData { Posts = posts.ToList() } } } + }, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + + string outputDir = Path.GetDirectoryName(outputPath) ?? BaseDir; + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + + using (var zipStream = new FileStream(outputPath, FileMode.Create)) + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) + { + var jsonEntry = archive.CreateEntry("import.json"); + using (var writer = new StreamWriter(jsonEntry.Open())) + { + await writer.WriteAsync(jsonString); + } + + if (Directory.Exists(MarkdownDir)) + { + foreach (string mdFile in Directory.GetFiles(MarkdownDir, "*.md", SearchOption.AllDirectories)) + { + string relativeMdPath = Path.GetFileName(mdFile); + var mdEntry = archive.CreateEntry($"content/{relativeMdPath}"); + using (var entryStream = mdEntry.Open()) + using (var fileStream = File.OpenRead(mdFile)) + { + await fileStream.CopyToAsync(entryStream); + } + } + } + + foreach (var file in ImageManager.CachedFiles) + { + string fullLocalPath = Path.Combine(Path.Combine(BaseDir, "images"), file.RelativePath); + if (File.Exists(fullLocalPath)) + { + var fileEntry = archive.CreateEntry($"content/images/{file.RelativePath}"); + using (var entryStream = fileEntry.Open()) + using (var fileStream = File.OpenRead(fullLocalPath)) + { + await fileStream.CopyToAsync(entryStream); + } + } + } + } + + Console.WriteLine(); + Console.WriteLine("========================================="); + Console.WriteLine($" Ghost ZIP erstellt!"); + Console.WriteLine($" Beiträge: {posts.Count}"); + Console.WriteLine($" Bilder: {ImageManager.CachedFiles.Count} Dateien"); + Console.WriteLine($" Ausgabe: {outputPath}"); + Console.WriteLine("========================================="); + } + catch (Exception ex) + { + Console.WriteLine($"\nFehler beim Erstellen der ZIP-Datei: {ex.Message}"); + Console.WriteLine($"Stack Trace: {ex.StackTrace}"); + } + } + } +} \ No newline at end of file diff --git a/CSharpGhostImporter/HtmlConverter.cs b/CSharpGhostImporter/HtmlConverter.cs new file mode 100644 index 0000000..b3618b6 --- /dev/null +++ b/CSharpGhostImporter/HtmlConverter.cs @@ -0,0 +1,570 @@ +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 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" }, + }; + + public static string Convert(string html) + { + if (string.IsNullOrWhiteSpace(html)) return string.Empty; + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html); + + int totalGalleries = 0; + totalGalleries += ConvertClassicGalleries(htmlDoc); + totalGalleries += ConvertGutenbergGalleries(htmlDoc); + totalGalleries += ConvertJetpackGalleries(htmlDoc); + totalGalleries += ConvertCoBlocksGalleries(htmlDoc); + + if (totalGalleries > 0) + Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert"); + + ConvertSingleImages(htmlDoc); + ConvertFigures(htmlDoc); + ConvertWpClasses(htmlDoc); + + return htmlDoc.DocumentNode.OuterHtml; + } + + // ========================================================================= + // Gallery-Typ 1: WordPress Classic Gallery + // HTML: + // ========================================================================= + 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; + + 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"); + + var wrappers = new List(); + foreach (var item in items) + { + var img = item.SelectSingleNode(".//img"); + if (img == null) continue; + + string src = GetBestImageUrl(img); + if (string.IsNullOrEmpty(src)) continue; + + wrappers.Add(BuildGalleryImage(htmlDoc, img, src)); + } + + if (wrappers.Count == 0) continue; + + AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); + ghostGallery.AppendChild(container); + + var captionNode = gallery.SelectSingleNode(".//*[contains(@class, 'gallery-caption')] | .//figcaption"); + if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) + { + var newCaption = htmlDoc.CreateElement("figcaption"); + newCaption.InnerHtml = captionNode.InnerHtml; + ghostGallery.AppendChild(newCaption); + ghostGallery.AddClass("kg-card-hascaption"); + } + + gallery.ParentNode?.ReplaceChild(ghostGallery, gallery); + count++; + } + return count; + } + + // ========================================================================= + // Gallery-Typ 2: WordPress Gutenberg Gallery + // ========================================================================= + private static int ConvertGutenbergGalleries(HtmlDocument htmlDoc) + { + var galleryNodes = htmlDoc.DocumentNode.SelectNodes( + "//figure[contains(@class, 'wp-block-gallery')] | " + + "//div[contains(@class, 'wp-block-gallery')] | " + + "//ul[contains(@class, 'wp-block-gallery')]" + ); + 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; + + 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"); + + var wrappers = new List(); + foreach (var img in imgs) + { + string src = GetBestImageUrl(img); + if (string.IsNullOrEmpty(src)) continue; + + wrappers.Add(BuildGalleryImage(htmlDoc, img, src)); + } + + if (wrappers.Count == 0) continue; + + AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); + ghostGallery.AppendChild(container); + + var captionNode = gallery.SelectSingleNode(".//figcaption"); + if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) + { + var newCaption = htmlDoc.CreateElement("figcaption"); + newCaption.InnerHtml = captionNode.InnerHtml; + ghostGallery.AppendChild(newCaption); + ghostGallery.AddClass("kg-card-hascaption"); + } + + gallery.ParentNode?.ReplaceChild(ghostGallery, gallery); + count++; + } + return count; + } + + // ========================================================================= + // Gallery-Typ 3: Jetpack Tiled Gallery + // ========================================================================= + private static int ConvertJetpackGalleries(HtmlDocument htmlDoc) + { + var galleryNodes = htmlDoc.DocumentNode.SelectNodes( + "//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " + + "//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]" + ); + 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; + + 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"); + + var wrappers = new List(); + foreach (var img in imgs) + { + string src = GetBestImageUrl(img); + if (string.IsNullOrEmpty(src)) continue; + + wrappers.Add(BuildGalleryImage(htmlDoc, img, src)); + } + + if (wrappers.Count == 0) continue; + + AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); + ghostGallery.AppendChild(container); + + gallery.ParentNode?.ReplaceChild(ghostGallery, gallery); + count++; + } + return count; + } + + // ========================================================================= + // Gallery-Typ 4: CoBlocks Gallery + // ========================================================================= + private static int ConvertCoBlocksGalleries(HtmlDocument htmlDoc) + { + var galleryNodes = htmlDoc.DocumentNode.SelectNodes( + "//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 (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; + + 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"); + + var wrappers = new List(); + foreach (var img in imgs) + { + string src = GetBestImageUrl(img); + if (string.IsNullOrEmpty(src)) continue; + + wrappers.Add(BuildGalleryImage(htmlDoc, img, src)); + } + + if (wrappers.Count == 0) continue; + + AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); + ghostGallery.AppendChild(container); + + gallery.ParentNode?.ReplaceChild(ghostGallery, gallery); + count++; + } + return count; + } + + // ========================================================================= + // Galerie-Helfer: Spaltenanzahl, Wide-Klasse, Bild-Wrapper, Zeilen + // ========================================================================= + private static int GetGalleryColumns(HtmlNode gallery) + { + string classes = gallery.GetAttributeValue("class", ""); + var match = Regex.Match(classes, @"columns-(\d+)"); + if (match.Success && int.TryParse(match.Groups[1].Value, out int cols) && cols >= 1 && cols <= 9) + return cols; + return 3; + } + + 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"); + } + + 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); + + double w = 0, h = 0; + + string origSize = originalImg.GetAttributeValue("data-orig-size", ""); + if (!string.IsNullOrEmpty(origSize)) + { + 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; + } + } + + if (w > 0 && h > 0) + { + 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%;"); + } + else + { + imgWrapper.SetAttributeValue("style", "flex: 1 1 0%;"); + } + + newImg.SetAttributeValue("loading", "lazy"); + newImg.SetAttributeValue("alt", originalImg.GetAttributeValue("alt", "")); + + imgWrapper.AppendChild(newImg); + return imgWrapper; + } + + private static void AppendGalleryRows(HtmlDocument htmlDoc, HtmlNode container, List 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; + + var parentFigure = img.Ancestors("figure").FirstOrDefault(); + if (parentFigure != null) + { + SetClasses(parentFigure, "kg-card kg-image-card"); + 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(); + 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); + } + } + } + + 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); + } + } +} diff --git a/CSharpGhostImporter/HtmlToMarkdownConverter.cs b/CSharpGhostImporter/HtmlToMarkdownConverter.cs new file mode 100644 index 0000000..b4ac0c6 --- /dev/null +++ b/CSharpGhostImporter/HtmlToMarkdownConverter.cs @@ -0,0 +1,143 @@ +namespace wp2ghost +{ + // ============================================================================= + // HTML zu Markdown Konverter + // ============================================================================= + public static class HtmlToMarkdownConverter + { + public static string Convert(string html) + { + if (string.IsNullOrEmpty(html)) + return ""; + + string result = html; + + result = System.Text.RegularExpressions.Regex.Replace(result, "", "", System.Text.RegularExpressions.RegexOptions.Singleline); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", m => new string('#', int.Parse(m.Groups[1].Value)) + " " + StripTags(m.Groups[2].Value) + Environment.NewLine + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", m => "> " + StripTags(m.Groups[1].Value).Replace(Environment.NewLine, Environment.NewLine + "> ") + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>]*>(.*?)", m => "```\n" + StripTags(m.Groups[1].Value) + "\n```\n\n", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", m => "`" + StripTags(m.Groups[1].Value) + "`", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", m => ConvertList(m.Groups[1].Value, "-"), System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", m => ConvertOrderedList(m.Groups[1].Value), System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = ConvertTables(result); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)

", m => CleanInnerHtml(m.Groups[1].Value) + Environment.NewLine + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"", Environment.NewLine, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", "**$1**", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", "**$1**", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", "*$1*", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*>(.*?)", "*$1*", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*href=[""']([^""']+)[""'][^>]*>(.*?)", "[$2]($1)", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*src=[""']([^""']+)[""'][^>]*alt=[""']([^""']*)[""'][^>]*>", m => "![" + m.Groups[2].Value + "](" + m.Groups[1].Value + ")", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*src=[""']([^""']+)[""'][^>]*>", m => "![](" + m.Groups[1].Value + ")", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"]*class=[""'][^""']*wp-block-heading[^""']*[""'][^>]*>(.*?)", m => "# " + StripTags(m.Groups[1].Value) + Environment.NewLine + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = System.Text.RegularExpressions.Regex.Replace(result, + @"]*>(.*?)", + m => ConvertFigure(m.Groups[1].Value), + System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + result = StripTagsKeepFigure(result); + + result = System.Text.RegularExpressions.Regex.Replace(result, @"\n{3,}", "\n\n"); + + result = result.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace(""", "\"").Replace("’", "'").Replace(" ", " ").Replace("—", "—").Replace("–", "–"); + + return result.Trim(); + } + + private static string StripTags(string html) => + System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", ""); + + private static string CleanInnerHtml(string html) + { + string cleaned = StripTags(html); + cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\*\*(.+?)\*\*", "**$1**"); + return cleaned.Trim(); + } + + private static string ConvertFigure(string figureContent) + { + string imgSrc = "", altText = "", caption = ""; + + var imgMatch = System.Text.RegularExpressions.Regex.Match(figureContent, @"]*src=[""']([^""']+)[""']"); + if (imgMatch.Success) imgSrc = imgMatch.Groups[1].Value; + + var altMatch = System.Text.RegularExpressions.Regex.Match(figureContent, @"alt=[""']([^""']*)[""']"); + if (altMatch.Success) altText = altMatch.Groups[1].Value; + + var figMatch = System.Text.RegularExpressions.Regex.Match(figureContent, @"]*>(.*?)", System.Text.RegularExpressions.RegexOptions.Singleline); + if (figMatch.Success) caption = System.Text.RegularExpressions.Regex.Replace(figMatch.Groups[1].Value, "<[^>]*>", "").Trim(); + + if (string.IsNullOrEmpty(imgSrc)) return ""; + + string result = $"
\"{altText}\"";{caption}"; + result += "
"; + return result + Environment.NewLine; + } + + private static string StripTagsKeepFigure(string html) + { + var figureBlocks = new List(); + html = System.Text.RegularExpressions.Regex.Replace(html, @"]*>.*?", m => { figureBlocks.Add(m.Value); return $"__FIGURE_{figureBlocks.Count - 1}__"; }, System.Text.RegularExpressions.RegexOptions.Singleline); + html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", ""); + for (int i = 0; i < figureBlocks.Count; i++) + html = html.Replace($"__FIGURE_{i}__", figureBlocks[i]); + return html; + } + + private static string ConvertList(string listHtml, string bullet) + { + listHtml = System.Text.RegularExpressions.Regex.Replace(listHtml, @"]*>(.*?)", m => bullet + " " + CleanInnerHtml(m.Groups[1].Value) + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return StripTags(listHtml).Trim() + Environment.NewLine; + } + + private static string ConvertOrderedList(string listHtml) + { + int counter = 0; + listHtml = System.Text.RegularExpressions.Regex.Replace(listHtml, @"]*>(.*?)", m => { counter++; return counter + ". " + CleanInnerHtml(m.Groups[1].Value) + Environment.NewLine; }, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return StripTags(listHtml).Trim() + Environment.NewLine; + } + + private static string ConvertTables(string html) + { + return System.Text.RegularExpressions.Regex.Replace(html, @"]*>(.*?)", m => + { + string tableContent = m.Groups[1].Value; + var headerMatch = System.Text.RegularExpressions.Regex.Match(tableContent, @"]*>(.*?)", System.Text.RegularExpressions.RegexOptions.Singleline); + if (!headerMatch.Success) return StripTags(tableContent) + Environment.NewLine; + + string headers = CleanInnerHtml(headerMatch.Groups[1].Value); + headers = headers.Replace("", ""); + string headerLine = "| " + string.Join(" | ", StripTags(headers).Split(new[] { "", " c.Trim())) + " |"; + string separator = "|" + string.Join("|", StripTags(headers).Split(new[] { "", " "---")) + "|"; + + var rows = System.Text.RegularExpressions.Regex.Matches(tableContent, @"]*>(.*?)", System.Text.RegularExpressions.RegexOptions.Singleline); + var dataRows = new List(); + for (int i = 1; i < rows.Count; i++) + { + string rowText = CleanInnerHtml(rows[i].Groups[1].Value); + string[] cells = rowText.Split(new[] { "", " c.Trim().Replace("", "").Replace("", "").Replace("", "").Replace("", ""))) + " |"); + } + + return headerLine + Environment.NewLine + separator + Environment.NewLine + string.Join(Environment.NewLine, dataRows) + Environment.NewLine + Environment.NewLine; + }, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + } +} \ No newline at end of file diff --git a/CSharpGhostImporter/ImageManager.cs b/CSharpGhostImporter/ImageManager.cs new file mode 100644 index 0000000..b610d7b --- /dev/null +++ b/CSharpGhostImporter/ImageManager.cs @@ -0,0 +1,221 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using System.Diagnostics.Metrics; + +namespace wp2ghost +{ + // ============================================================================= + // ImageManager — Bilder herunterladen, cachen, lokal verwalten + // ============================================================================= + static class ImageManager + { + private static readonly HttpClient httpClient = new(); + private static Dictionary Cache = new(); + private static List<(string RelativePath, string LocalPath)> FilesToZip = new(); + private static string LocalImageDir = ""; + private static string CachePath = ""; + + + public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip; + public static bool TryGetRelativePath(string url, out string relativePath) + { + if (Cache.TryGetValue(url, out var cached)) + { + relativePath = cached.RelativePath; + return true; + } + relativePath = ""; + return false; + } + + public static void Init(string baseDir) + { + LocalImageDir = Path.Combine(baseDir, "images"); + CachePath = Path.Combine(baseDir, "imagecache.txt"); + + if (!Directory.Exists(LocalImageDir)) + Directory.CreateDirectory(LocalImageDir); + + LoadCache(); + } + + public static string ImageContainerPath => LocalImageDir; + + public static void SaveCache() + { + var lines = new List(); + foreach (var kvp in Cache) + lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}"); + File.WriteAllLines(CachePath, lines); + } + + public static void ClearAll() + { + if (Directory.Exists(LocalImageDir)) + { + Directory.Delete(LocalImageDir, true); + Directory.CreateDirectory(LocalImageDir); + } + Cache.Clear(); + FilesToZip.Clear(); + SaveCache(); + Console.WriteLine("Bilder wurden lokal gelöscht."); + } + + public static void ShowInfo() + { + if (Cache.Count > 0) + { + Console.WriteLine("\nLokal gespeicherte Bilder:"); + foreach (var kvp in Cache.Take(20)) + Console.WriteLine($" {kvp.Key} -> images/{kvp.Value.RelativePath}"); + if (Cache.Count > 20) + Console.WriteLine($" ... und {Cache.Count - 20} weitere"); + } + } + + public static bool IsMainImage(string url) + { + if (string.IsNullOrEmpty(url)) return false; + try + { + var uri = new Uri(url); + string fileName = Path.GetFileName(uri.LocalPath); + // WordPress-Sizes: -300x200, -scaled, -1024x768 etc. + if (System.Text.RegularExpressions.Regex.IsMatch(fileName, @"-\d+x\d+(?=\.\w+$)")) + return false; + if (fileName.Contains("-scaled")) + return false; + return true; + } + catch { return false; } + } + + public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string imageUrl, DateTime? postDate) + { + if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) + return null; + + if (Cache.TryGetValue(imageUrl, out var cachedFile)) + { + if (File.Exists(cachedFile.LocalPath)) + return cachedFile; + Cache.Remove(imageUrl); + } + + try + { + string fileName = Path.GetFileName(new Uri(imageUrl).AbsolutePath); + string urlHash = GetHashCode(imageUrl); + var datePrefix = DateTime.Now; + string datePath = $"{datePrefix:yyyy}/{datePrefix:MM}"; + var localPath = Path.Combine(LocalImageDir, datePath); + if (!string.IsNullOrEmpty(localPath) && !Directory.Exists(localPath)) + Directory.CreateDirectory(localPath); + + var counter = 1 + new DirectoryInfo(localPath).GetFiles().Length; + var pDate = postDate ?? DateTime.Now; + string uniqueFileName = $"{pDate:yyyymmdd}_{counter}{Path.GetExtension(fileName)}"; + string relativePath = $"{datePath}/{uniqueFileName}"; + string fallBackPath = $"{datePath}/{uniqueFileName}"; + localPath = Path.Combine(LocalImageDir, relativePath); + fallBackPath = Path.Combine(LocalImageDir, fallBackPath); + + + + + if (File.Exists(fallBackPath)) + { + File.Move(fallBackPath, localPath); + Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}"); + } + else if (!File.Exists(localPath)) + { + byte[] imageBytes = await httpClient.GetByteArrayAsync(imageUrl); + await File.WriteAllBytesAsync(localPath, imageBytes); + Console.WriteLine($" [Bild] Heruntergeladen: {relativePath}"); + } + else + { + Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}"); + } + + ResizeImage(localPath); + + var fileResult = (relativePath, localPath); + Cache[imageUrl] = fileResult; + FilesToZip.Add(fileResult); + return fileResult; + } + catch (Exception ex) + { + Console.WriteLine($" [Bild-Fehler] Konnte {imageUrl} nicht laden: {ex.Message}"); + return null; + } + } + + private static void ResizeImage(string localPath) + { + const int maxSize = 1024; + try + { + using var image = Image.Load(localPath); + int width = image.Width; + int height = image.Height; + + int longEdge = Math.Max(width, height); + if (longEdge <= maxSize) return; + + int newWidth, newHeight; + if (width >= height) + { + newWidth = maxSize; + newHeight = (int)Math.Round(height * ((double)maxSize / width)); + } + else + { + newHeight = maxSize; + newWidth = (int)Math.Round(width * ((double)maxSize / height)); + } + + image.Mutate(x => x.Resize(newWidth, newHeight)); + image.Save(localPath); + Console.WriteLine($" [Bild] Angepasst: {width}x{height} → {newWidth}x{newHeight}"); + } + catch (Exception ex) + { + Console.WriteLine($" [Bild-Warnung] Resize fehlgeschlagen für {Path.GetFileName(localPath)}: {ex.Message}"); + } + } + + private static string GetHashCode(string input) + { + unchecked + { + int hash = 17; + foreach (char c in input) + hash = hash * 23 + c; + return hash.ToString("x8"); + } + } + + private static void LoadCache() + { + if (!File.Exists(CachePath)) return; + try + { + var lines = File.ReadAllLines(CachePath); + foreach (var line in lines) + { + var parts = line.Split('|'); + if (parts.Length == 3) + Cache[parts[0]] = (parts[1], parts[2]); + } + } + catch (Exception ex) + { + Console.WriteLine($"Fehler beim Laden des Bild-Caches: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/CSharpGhostImporter/PostFetcher.cs b/CSharpGhostImporter/PostFetcher.cs new file mode 100644 index 0000000..a8e38db --- /dev/null +++ b/CSharpGhostImporter/PostFetcher.cs @@ -0,0 +1,487 @@ +using System.Text.Json; + +namespace wp2ghost +{ + // ============================================================================= + // PostFetcher — Posts von WordPress abrufen, verarbeiten + // ============================================================================= + static class PostFetcher + { + private static readonly HttpClient httpClient = new(); + private static HashSet ProcessedPosts = new(); + private static List GhostPosts = new(); + private static string ProcessedPostsPath = ""; + //private static string MarkdownDir = ""; + private static string GhostPostsDir = ""; + private static string BaseDir = ""; + + public static int ProcessedCount => ProcessedPosts.Count; + public static int PostCount => GhostPosts.Count; + public static IReadOnlyList Posts => GhostPosts; + + public static void Init(string baseDir) + { + BaseDir = baseDir; + ProcessedPostsPath = Path.Combine(baseDir, "processed_posts.txt"); + //MarkdownDir = Path.Combine(baseDir, "markdown"); + GhostPostsDir = Path.Combine(baseDir, "ghostPosts"); + + ////if (!Directory.Exists(MarkdownDir)) + //// Directory.CreateDirectory(MarkdownDir); + if (!Directory.Exists(GhostPostsDir)) + Directory.CreateDirectory(GhostPostsDir); + + LoadProcessedPosts(); + } + + public static void ClearProcessedPosts() + { + ProcessedPosts.Clear(); + File.WriteAllText(ProcessedPostsPath, "wp_id,wp_slug,ghost_id" + Environment.NewLine); + Console.WriteLine("Abgefragte Posts wurden zurückgesetzt."); + } + + public static void ShowInfo() + { + if (ProcessedPosts.Count > 0) + { + Console.WriteLine($"Letzte abgefragte Objekte: {string.Join(", ", ProcessedPosts.OrderByDescending(x => x).Take(10))}"); + } + } + + public static async Task FetchFromWordPressAsync(string wpBaseUrl, int maxPosts) + { + Console.WriteLine(); + Console.WriteLine("Starte Abruf von WordPress..."); + + string wpHost = ""; + try { wpHost = new Uri(wpBaseUrl).Host.ToLowerInvariant(); } catch { } + + try + { + var allPostIds = new List(); + var postTypes = new[] { "post", "page" }; + + foreach (var postType in postTypes) + { + Console.WriteLine($"Hole {postType}-IDs von der WordPress-API..."); + string idsUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/posts-ids?post_type={postType}"; + var idsResponse = await httpClient.GetStringAsync(idsUrl); + var idsObj = JsonSerializer.Deserialize(idsResponse); + if (idsObj?.Ids != null) + allPostIds.AddRange(idsObj.Ids); + } + + if (allPostIds.Count == 0) + { + Console.WriteLine("Keine Beiträge oder Seiten gefunden."); + return; + } + + var pendingIds = allPostIds.Where(id => !ProcessedPosts.Contains(id)).ToList(); + Console.WriteLine($"Insgesamt gefunden: {allPostIds.Count}, Bereits abgefragt: {allPostIds.Count - pendingIds.Count}, Noch zu verarbeiten: {pendingIds.Count}"); + + if (pendingIds.Count == 0) + { + Console.WriteLine("Alle Beiträge und Seiten wurden bereits abgefragt. Nichts zu tun. (Nutze Option 5 zum Zurücksetzen)"); + return; + } + + if (maxPosts > 0) + pendingIds = pendingIds.Take(maxPosts).ToList(); + + Console.WriteLine($"Es werden {pendingIds.Count} Beiträge/Seiten verarbeitet."); + + int internalId = 1; + if (GhostPosts.Count > 0) + { + var lastId = GhostPosts.Select(gp => int.TryParse(gp.Id, out int id) ? id : 0).Max(); + internalId = lastId + 1; + } + int processed = 0; + + foreach (int wpId in pendingIds) + { + Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}..."); + string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}"; + + try + { + var detailJson = await httpClient.GetStringAsync(detailUrl); + var wpPost = JsonSerializer.Deserialize(detailJson); + + if (wpPost == null) + { + Console.WriteLine($" Übersprungen (null)."); + MarkProcessed(wpId, null); + continue; + } + + string htmlContent = wpPost.Content ?? ""; + var parsedDate = DateTime.MinValue; + var postDate = DateTime.TryParse(wpPost.Date, out parsedDate) ? parsedDate : new DateTime(2026, 9, 1); + + // Alle internen Bild-URLs aus dem HTML extrahieren (keine externen Links) + var htmlImageUrls = ExtractImageUrls(htmlContent, wpHost); + + // Bevorzugte Media-Dateien ermitteln + var preferredMedia = GetPreferredMediaFiles(wpPost.MediaFiles, htmlImageUrls, wpHost); + + // Prüfe: Referenzierte Bilder im HTML, die NICHT in MediaFiles sind + if (htmlImageUrls.Count > 0 && wpPost.MediaFiles != null) + { + var mediaFileNames = wpPost.MediaFiles.Select(m => GetFileNameFromUrl(m).ToLowerInvariant()).ToHashSet(); + foreach (var imgUrl in htmlImageUrls) + { + string imgFileName = GetFileNameFromUrl(imgUrl).ToLowerInvariant(); + if (!mediaFileNames.Contains(imgFileName)) + { + Console.WriteLine($" [Warnung] Bild im HTML nicht in MediaFiles: {Path.GetFileName(new Uri(imgUrl).AbsolutePath)}"); + } + } + } + + if (preferredMedia.Count > 0) + { + foreach (string mediaUrl in preferredMedia) + { + var downloaded = await ImageManager.DownloadAsync(mediaUrl, postDate); + if (downloaded.HasValue) + { + string ghostMediaPath = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}"; + htmlContent = htmlContent.Replace(mediaUrl, ghostMediaPath); + } + } + } + + // WordPress HTML zu Ghost-kompatibles HTML konvertieren (Galerien, Figures, etc.) + htmlContent = HtmlGhostConverter.Convert(htmlContent); + + // Lokale Bildpfade für JSON-Datei sammeln + var imageFiles = new List(); + if (preferredMedia.Count > 0) + { + foreach (string mediaUrl in preferredMedia) + { + if (ImageManager.TryGetRelativePath(mediaUrl, out string relPath)) + imageFiles.Add(relPath); + } + } + + // Featured Image verarbeiten + string featureImageGhostPath = ""; + if (!string.IsNullOrEmpty(wpPost.FeaturedImage)) + { + if (ImageManager.TryGetRelativePath(wpPost.FeaturedImage, out string featRel)) + { + featureImageGhostPath = $"__GHOST_URL__/content/images/{featRel}"; + } + else + { + var featDownload = await ImageManager.DownloadAsync(wpPost.FeaturedImage, postDate); + if (featDownload.HasValue) + { + featureImageGhostPath = $"__GHOST_URL__/content/images/{featDownload.Value.RelativePath}"; + if (!imageFiles.Contains(featDownload.Value.RelativePath)) + imageFiles.Add(featDownload.Value.RelativePath); + } + } + } + + var ghostPost = new GhostPost + { + Id = internalId.ToString(), + Title = wpPost.Title, + Slug = wpPost.Slug, + Html = htmlContent, + Status = "published", + CreatedAt = postDate.ToString("o"), + PublishedAt = postDate.ToString("o") + }; + + GhostPosts.Add(ghostPost); + + // JSON-Datei in ghostPosts/ speichern + var postFile = new GhostPostFile + { + WpId = wpId, + WpSlug = wpPost.Slug, + GhostId = ghostPost.Id, + Title = wpPost.Title, + Slug = wpPost.Slug, + Excerpt = wpPost.Excerpt, + Status = "published", + CreatedAt = postDate.ToString("o"), + PublishedAt = postDate.ToString("o"), + FeatureImage = featureImageGhostPath, + Categories = wpPost.Categories ?? new(), + Keywords = wpPost.Keywords ?? "", + Comments = wpPost.Comments ?? new(), + ImageFiles = imageFiles, + Html = htmlContent + }; + string jsonPath = Path.Combine(GhostPostsDir, $"{postFile.WpId}.json"); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(postFile, new JsonSerializerOptions { WriteIndented = true })); + + MarkProcessed(wpId, wpPost.Slug); + internalId++; + } + catch (Exception ex) + { + Console.WriteLine($" Fehler beim Abrufen von ID {wpId}: {ex.Message}"); + MarkProcessed(wpId, null); + } + } + + Console.WriteLine(); + Console.WriteLine("========================================="); + Console.WriteLine($" Abruf abgeschlossen! {GhostPosts.Count} Beiträge geladen."); + Console.WriteLine($" Abgefragte Posts insgesamt: {ProcessedPosts.Count}"); + Console.WriteLine(" Nutze Option 2 um die Ghost ZIP zu erstellen."); + Console.WriteLine("========================================="); + } + catch (Exception ex) + { + Console.WriteLine($"\nFehler beim Abruf: {ex.Message}"); + Console.WriteLine($"Stack Trace: {ex.StackTrace}"); + } + } + + //public static void SaveAllMarkdownFiles() + //{ + // foreach (var post in GhostPosts) + // { + // if (string.IsNullOrEmpty(post.Slug)) continue; + // string mdPath = Path.Combine(MarkdownDir, $"{post.Slug}.md"); + // if (!string.IsNullOrEmpty(post.Markdown)) + // File.WriteAllText(mdPath, post.Markdown); + // } + // Console.WriteLine($"Markdown-Dateien gespeichert: {GhostPosts.Count} Dateien unter {MarkdownDir}"); + //} + + private static List ExtractImageUrls(string html, string wpHost) + { + var urls = new List(); + var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" }; + + // 1) extrahieren + var imgMatches = System.Text.RegularExpressions.Regex.Matches(html, @"]*src=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + foreach (System.Text.RegularExpressions.Match m in imgMatches) + { + string url = m.Groups[1].Value; + if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url)) + urls.Add(url); + } + + // 2) data-orig-file (Vollbild) extrahieren + var origFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-orig-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + foreach (System.Text.RegularExpressions.Match m in origFileMatches) + { + string url = m.Groups[1].Value; + if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url)) + urls.Add(url); + } + + // 3) data-large-file extrahieren + var largeFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-large-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + foreach (System.Text.RegularExpressions.Match m in largeFileMatches) + { + string url = m.Groups[1].Value; + if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url)) + urls.Add(url); + } + + // 4) srcset (größte Variante pro img) extrahieren + var srcsetMatches = System.Text.RegularExpressions.Regex.Matches(html, @"srcset=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + foreach (System.Text.RegularExpressions.Match m in srcsetMatches) + { + string srcset = m.Groups[1].Value; + string widestUrl = GetWidestFromSrcset(srcset); + if (!string.IsNullOrEmpty(widestUrl) && IsInternalUrl(widestUrl, wpHost) && !urls.Contains(widestUrl)) + urls.Add(widestUrl); + } + + // 5) mit Bildendung (Figure-Links auf Originalbild) + var aMatches = System.Text.RegularExpressions.Regex.Matches(html, @"]*href=[""']([^""']+)[""'][^>]*>", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + foreach (System.Text.RegularExpressions.Match m in aMatches) + { + string url = m.Groups[1].Value; + if (string.IsNullOrEmpty(url)) continue; + try + { + string ext = Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant(); + if (imageExts.Contains(ext) && IsInternalUrl(url, wpHost) && !urls.Contains(url)) + urls.Add(url); + } + catch { } + } + + return urls; + } + + private static string GetWidestFromSrcset(string srcset) + { + string bestUrl = ""; + int bestWidth = 0; + foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + string[] tokens = part.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 2) continue; + if (int.TryParse(tokens[1].Replace("w", ""), out int w) && w > bestWidth) + { + bestWidth = w; + bestUrl = tokens[0]; + } + } + return bestUrl; + } + + private static bool IsInternalUrl(string url, string wpHost) + { + if (string.IsNullOrEmpty(wpHost)) return true; + try + { + var uri = new Uri(url); + return uri.Host.ToLowerInvariant().EndsWith(wpHost) || wpHost.EndsWith(uri.Host.ToLowerInvariant()); + } + catch + { + return url.StartsWith("/") || url.StartsWith("wp-content"); + } + } + + private static string NormalizeImageUrl(string url) + { + try + { + var uri = new Uri(url); + string path = uri.AbsolutePath; + // Entferne -scaled UND Dimensionspostfixe für den Vergleich + path = System.Text.RegularExpressions.Regex.Replace(path, @"-scaled(?=\.\w+$)", ""); + path = System.Text.RegularExpressions.Regex.Replace(path, @"-\d+x\d+(?=\.\w+$)", ""); + return path; + } + catch + { + return url; + } + } + + private static string GetFileNameFromUrl(string url) + { + try + { + return Path.GetFileName(new Uri(url).AbsolutePath); + } + catch + { + return url; + } + } + + private static List GetPreferredMediaFiles(List? mediaFiles, List htmlImageUrls, string wpHost) + { + var result = new List(); + var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" }; + + // Alle internen Bild-URLs zusammenführen: MediaFiles + HTML-referenzierte + var allUrls = new List(); + if (mediaFiles != null) + allUrls.AddRange(mediaFiles.Where(u => IsInternalUrl(u, wpHost))); + foreach (var u in htmlImageUrls) + { + if (!allUrls.Contains(u)) + allUrls.Add(u); + } + + if (allUrls.Count == 0) + return result; + + // Dateinamen aus den HTML-Links extrahieren + var htmlFileNames = htmlImageUrls.Select(u => GetFileNameFromUrl(u).ToLowerInvariant()).ToHashSet(); + + // Gruppiere nach normalisiertem Dateinamen (ohne -scaled und Dimensionspostfixe) + var grouped = new Dictionary>(); + foreach (var url in allUrls) + { + string normalized = NormalizeImageUrl(url); + if (!grouped.ContainsKey(normalized)) + grouped[normalized] = new List(); + grouped[normalized].Add(url); + } + + foreach (var group in grouped) + { + // Nur Hauptbilder berücksichtigen (keine WordPress-Sizes wie -300x200, keine -scaled) + var mainImages = group.Value.Where(u => ImageManager.IsMainImage(u)).ToList(); + if (mainImages.Count == 0) + { + Console.WriteLine($" [Bild] Übersprungen (kein Hauptbild): {Path.GetFileName(new Uri(@group.Value[0]).AbsolutePath)}"); + continue; + } + + if (mainImages.Count == 1) + { + result.Add(mainImages[0]); + } + else + { + // Prüfe ob eine der Varianten als im HTML referenziert wird + string? linked = mainImages.FirstOrDefault(u => htmlFileNames.Contains(GetFileNameFromUrl(u).ToLowerInvariant())); + + if (linked != null) + { + result.Add(linked); + var skipped = mainImages.Where(u => u != linked).ToList(); + foreach (var s in skipped) + Console.WriteLine($" [Bild] Übersprungen (nicht verlinkt): {Path.GetFileName(new Uri(s).AbsolutePath)}"); + } + else + { + // Keine Verlinkung: Original (ohne -scaled) bevorzugen + var preferred = mainImages.FirstOrDefault(u => !u.Contains("-scaled")) ?? mainImages[0]; + result.Add(preferred); + var skipped = mainImages.Where(u => u != preferred).ToList(); + foreach (var s in skipped) + Console.WriteLine($" [Bild] Übersprungen (scaled): {Path.GetFileName(new Uri(s).AbsolutePath)}"); + } + } + } + + return result; + } + + private static void MarkProcessed(int wpId, string? wpSlug = null) + { + ProcessedPosts.Add(wpId); + SaveProcessedPosts(wpId, wpSlug); + } + + private static void LoadProcessedPosts() + { + if (!File.Exists(ProcessedPostsPath)) return; + try + { + var lines = File.ReadAllLines(ProcessedPostsPath); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("wp_id")) continue; + var parts = line.Split(','); + if (parts.Length >= 1 && int.TryParse(parts[0].Trim(), out int postId)) + ProcessedPosts.Add(postId); + } + } + catch (Exception ex) + { + Console.WriteLine($"Fehler beim Laden der abgefragten Posts: {ex.Message}"); + } + } + + private static void SaveProcessedPosts(int wpId, string? wpSlug) + { + string entry = $"{wpId},{wpSlug ?? ""}"; + using var writer = File.AppendText(ProcessedPostsPath); + writer.WriteLine(entry); + } + } +} \ No newline at end of file diff --git a/CSharpGhostImporter/WPDto.cs b/CSharpGhostImporter/WPDto.cs new file mode 100644 index 0000000..c8336d4 --- /dev/null +++ b/CSharpGhostImporter/WPDto.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace wp2ghost +{ + public class IdsResponse + { + [JsonPropertyName("count")] public int Count { get; set; } + [JsonPropertyName("post_type")] public string? PostType { get; set; } + [JsonPropertyName("ids")] public List? Ids { get; set; } + } + + public class WpPostResponse + { + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("title")] public string? Title { get; set; } + [JsonPropertyName("slug")] public string? Slug { get; set; } + [JsonPropertyName("date")] public string? Date { get; set; } + [JsonPropertyName("content")] public string? Content { get; set; } + [JsonPropertyName("excerpt")] public string? Excerpt { get; set; } + [JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; } + [JsonPropertyName("categories")] public List? Categories { get; set; } = new(); + [JsonPropertyName("keywords")] public string? Keywords { get; set; } + [JsonPropertyName("comments")] public List? Comments { get; set; } = new(); + [JsonPropertyName("media_files")] public List? MediaFiles { get; set; } = new(); + } + + public class WpCategory + { + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("slug")] public string? Slug { get; set; } + } + + public class WpComment + { + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("author_name")] public string? AuthorName { get; set; } + [JsonPropertyName("author_email")] public string? AuthorEmail { get; set; } + [JsonPropertyName("author_url")] public string? AuthorUrl { get; set; } + [JsonPropertyName("content")] public string? Content { get; set; } + [JsonPropertyName("date")] public string? Date { get; set; } + } +} diff --git a/CSharpGhostImporter/config.txt b/CSharpGhostImporter/config.txt new file mode 100644 index 0000000..d6f09e7 --- /dev/null +++ b/CSharpGhostImporter/config.txt @@ -0,0 +1,6 @@ +# WP2Ghost Konfiguration +# Ändere die Werte und starte das Programm neu + +wpBaseUrl=https://deine-domain.com +outputZipPath=ghost_import.zip +maxPosts=-1 diff --git a/CSharpGhostImporter/imagecache.txt b/CSharpGhostImporter/imagecache.txt new file mode 100644 index 0000000..9da8c1a --- /dev/null +++ b/CSharpGhostImporter/imagecache.txt @@ -0,0 +1,3 @@ +# Bild-Cache für WP2Ghost +# Format: imageUrl|relativePath|localTempPath +# Automatisch vom Programm generiert. Nicht manuell bearbeiten. diff --git a/CSharpGhostImporter/processed_posts.txt b/CSharpGhostImporter/processed_posts.txt new file mode 100644 index 0000000..b99c2c3 --- /dev/null +++ b/CSharpGhostImporter/processed_posts.txt @@ -0,0 +1,3 @@ +# Abgefragte Posts für WP2Ghost +# Eine Post-ID pro Zeile +# Automatisch vom Programm generiert. Nicht manuell bearbeiten. diff --git a/CSharpGhostImporter/test_gallery.html b/CSharpGhostImporter/test_gallery.html new file mode 100644 index 0000000..de3334e --- /dev/null +++ b/CSharpGhostImporter/test_gallery.html @@ -0,0 +1,105 @@ +

Am letzten Karnevalwochenende 2017 haben wir Venedig besucht.

+

Trotz der Massen und teilweise langen Wartenzeiten, dank der Terrorprävention, war es ein schönes Wochenende.

+

Hier ein paar Impressionen.

+
+ +

 

+

 

diff --git a/CSharpGhostImporter/wp2ghost.slnx b/CSharpGhostImporter/wp2ghost.slnx new file mode 100644 index 0000000..1260ade --- /dev/null +++ b/CSharpGhostImporter/wp2ghost.slnx @@ -0,0 +1,3 @@ + + + diff --git a/WPPlugin/rbrExportRestApi.php b/WPPlugin/rbrExportRestApi.php new file mode 100644 index 0000000..d1bf907 --- /dev/null +++ b/WPPlugin/rbrExportRestApi.php @@ -0,0 +1,396 @@ + 'GET', + 'callback' => 'rbr_get_all_post_ids', + 'permission_callback' => '__return_true', + 'args' => array( + 'post_type' => array( + 'default' => 'post', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'per_page' => array( + 'default' => -1, + 'sanitize_callback' => 'rbr_sanitize_per_page', + ), + ), + )); + + // 2. Endpunkt: Content & Medien für eine bestimmte Post-ID + register_rest_route('custom/v1', '/post-details/(?P\d+)', array( + 'methods' => 'GET', + 'callback' => 'rbr_get_post_details_with_media', + 'permission_callback' => '__return_true', + )); + + // 3. Endpunkt: Kategorien/Tags für alle Beiträge + register_rest_route('custom/v1', '/post-taxonomies/(?P\d+)', array( + 'methods' => 'GET', + 'callback' => 'rbr_get_post_taxonomies', + 'permission_callback' => '__return_true', + )); + + // 4. Endpunkt: Alle Medien-Dateien (Attachments) abrufen + register_rest_route('custom/v1', '/media', array( + 'methods' => 'GET', + 'callback' => 'rbr_get_all_media', + 'permission_callback' => '__return_true', + 'args' => array( + 'per_page' => array( + 'default' => -1, + 'sanitize_callback' => 'rbr_sanitize_per_page', + ), + ), + )); + + // 5. Endpunkt: Medien-Datei direkt über ID abrufen + register_rest_route('custom/v1', '/media/(?P\d+)', array( + 'methods' => 'GET', + 'callback' => 'rbr_get_media_by_id', + 'permission_callback' => '__return_true', + )); +}); + +/** + * Liefert ein Array mit allen veröffentlichten Post-IDs. + * Unterstützt verschiedene Post-Types (post, page, custom). + */ +function rbr_get_all_post_ids($request) { + $post_type = sanitize_text_field($request['post_type']); + $per_page = rbr_sanitize_per_page($request['per_page']); + + $args = array( + 'post_type' => $post_type, + 'post_status' => 'publish', + 'numberposts' => $per_page, + 'fields' => 'ids', + 'orderby' => 'date', + 'order' => 'ASC', + ); + + $post_ids = get_posts($args); + + if (empty($post_ids)) { + return new WP_Error('no_posts', 'Keine Beiträge gefunden.', array('status' => 404)); + } + + return rest_ensure_response(array( + 'count' => count($post_ids), + 'post_type' => $post_type, + 'ids' => $post_ids, + )); +} + +/** + * Liefert Content, Featured Image, Kategorien, Keywords, Comments und alle im HTML/Post genutzten Medien-URLs. + */ +function rbr_get_post_details_with_media($data) { + $post_id = intval($data['id']); + $post = get_post($post_id); + + if (!$post || $post->post_status !== 'publish') { + return new WP_Error('no_post', 'Beitrag nicht gefunden.', array('status' => 404)); + } + + $content = apply_filters('the_content', $post->post_content); + $media_urls = array(); + + // 1. Featured Image (Titelbild) auslesen + $featured_image_url = ''; + $featured_image_id = get_post_thumbnail_id($post_id); + if ($featured_image_id) { + $featured_image_url = wp_get_attachment_url($featured_image_id); + if ($featured_image_url) { + $media_urls[] = $featured_image_url; + } + } + + // 2. Inhaltsbilder aus dem HTML extrahieren (via Regex für Tags) + preg_match_all('/]+src=["\']([^"\']+)["\']/i', $post->post_content, $matches); + if (!empty($matches[1])) { + foreach ($matches[1] as $img_url) { + if (filter_var($img_url, FILTER_VALIDATE_URL)) { + $media_urls[] = $img_url; + } + } + } + + // 3. Attachments die mit dem Post verknüpft sind + $attachments = get_posts(array( + 'post_type' => 'attachment', + 'numberposts' => -1, + 'post_status' => null, + 'post_parent' => $post_id, + 'fields' => 'ids', + 'post_mime_type' => 'image', + )); + + foreach ($attachments as $att_id) { + $att_url = wp_get_attachment_url($att_id); + if ($att_url) { + $media_urls[] = $att_url; + } + } + + // Duplikate entfernen + $unique_media = array_values(array_unique($media_urls)); + + // 4. Kategorien ( Categories Only, keine Tags) + $categories = array(); + $cats = get_the_category($post_id); + if (!empty($cats) && !is_wp_error($cats)) { + foreach ($cats as $cat) { + $categories[] = array( + 'id' => intval($cat->term_id), + 'name' => $cat->name, + 'slug' => $cat->slug, + ); + } + } + + // 5. Keywords (aus Yoast SEO oder Rank Math oder Custom Field) + $keywords = ''; + // Yoast SEO + if (function_exists('yoast_get_primary_term')) { + $keywords = get_post_meta($post_id, '_yoast_wpseo_focuskw', true); + } + // Rank Math + if (empty($keywords) && function_exists('rank_math_get_single_post_data')) { + $rank_data = rank_math_get_single_post_data($post_id); + if (!empty($rank_data->get_keywords())) { + $keywords = $rank_data->get_keywords(); + } + } + // Fallback: Custom Field 'post_keywords' + if (empty($keywords)) { + $keywords = get_post_meta($post_id, 'post_keywords', true); + } + // Fallback: Kategorien als Keywords + if (empty($keywords) && !empty($categories)) { + $keywords = implode(', ', array_column($categories, 'name')); + } + + // 6. Kommentare + $comments = array(); + $wp_comments = get_comments(array( + 'post_id' => $post_id, + 'status' => 'approve', + 'number' => 100, + )); + foreach ($wp_comments as $comment) { + $comments[] = array( + 'id' => intval($comment->comment_ID), + 'author_name' => $comment->comment_author, + 'author_email' => $comment->comment_author_email, + 'author_url' => $comment->comment_author_url, + 'content' => $comment->comment_content, + 'date' => mysql_to_rfc3339($comment->comment_date), + ); + } + + $response = array( + 'id' => intval($post->ID), + 'title' => $post->post_title, + 'slug' => $post->post_name, + 'date' => mysql_to_rfc3339($post->post_date), + 'content' => $content, + 'excerpt' => wp_trim_words(strip_tags($post->post_excerpt), 55), + 'author' => array( + 'id' => intval($post->post_author), + 'name' => get_the_author_meta('display_name', $post->post_author), + ), + 'featured_image' => $featured_image_url, + 'categories' => $categories, + 'keywords' => $keywords, + 'comments' => $comments, + 'media_files' => $unique_media, + ); + + return rest_ensure_response($response); +} + +/** + * Liefert Kategorien und Tags für einen bestimmten Beitrag. + */ +function rbr_get_post_taxonomies($data) { + $post_id = intval($data['id']); + $post = get_post($post_id); + + if (!$post || $post->post_status !== 'publish') { + return new WP_Error('no_post', 'Beitrag nicht gefunden.', array('status' => 404)); + } + + $taxonomies = get_object_taxonomies('post'); + $terms = array(); + + foreach ($taxonomies as $taxonomy) { + $terms_list = get_the_terms($post_id, $taxonomy); + if (!empty($terms_list) && !is_wp_error($terms_list)) { + foreach ($terms_list as $term) { + $terms[] = array( + 'id' => intval($term->term_id), + 'name' => $term->name, + 'slug' => $term->slug, + 'type' => $taxonomy, + ); + } + } + } + + return rest_ensure_response(array( + 'post_id' => $post_id, + 'taxonomies' => $terms, + )); +} + +/** + * Liefert alle Medien-Dateien (Attachments) der WordPress-Instanz. + */ +function rbr_get_all_media($request) { + $per_page = rbr_sanitize_per_page($request['per_page']); + + $args = array( + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'numberposts' => $per_page, + 'orderby' => 'date', + 'order' => 'ASC', + 'post_mime_type' => array('image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'), + ); + + $attachments = get_posts($args); + $media_list = array(); + + foreach ($attachments as $att) { + $media_list[] = array( + 'id' => intval($att->ID), + 'url' => wp_get_attachment_url($att->ID), + 'title' => $att->post_title, + 'alt' => get_post_meta($att->ID, '_wp_attachment_image_alt', true), + 'mime' => $att->post_mime_type, + 'date' => mysql_to_rfc3339($att->post_date), + ); + } + + return rest_ensure_response(array( + 'count' => count($media_list), + 'media' => $media_list, + )); +} + +/** + * Liefert eine einzelne Medien-Datei anhand ihrer ID. + */ +function rbr_get_media_by_id($data) { + $media_id = intval($data['id']); + $attachment = get_post($media_id); + + if (!$attachment || $attachment->post_type !== 'attachment') { + return new WP_Error('no_media', 'Medien-Datei nicht gefunden.', array('status' => 404)); + } + + return rest_ensure_response(array( + 'id' => intval($attachment->ID), + 'url' => wp_get_attachment_url($attachment->ID), + 'title' => $attachment->post_title, + 'alt' => get_post_meta($attachment->ID, '_wp_attachment_image_alt', true), + 'mime' => $attachment->post_mime_type, + 'date' => mysql_to_rfc3339($attachment->post_date), + )); +} + +/** + * Shortcode: [rbr_export] – Zeigt eine Export-Statistik im Frontend an. + */ +function rbr_export_shortcode($atts) { + $atts = shortcode_atts(array( + 'post_type' => 'post', + ), $atts, 'rbr_export'); + + $count = wp_count_posts($atts['post_type']); + $published = $count->publish ?? 0; + + return sprintf( + '

Es gibt %d veröffentlichte %s-Beiträge, die über die REST-API exportiert werden können.

', + intval($published), + esc_html($atts['post_type']) + ); +} +add_shortcode('rbr_export', 'rbr_export_shortcode'); + +/** + * Admin-Notiz: Plugin ist aktiv und bereit für den Export. + */ +add_action('admin_notices', function () { + if (current_user_can('manage_options')) { + echo '

'; + printf( + __('RBR Export to Ghost ist aktiv. REST-API-Endpunkte stehen unter /wp-json/custom/v1/ zur Verfügung.', 'rbr-export-ghost'), + admin_url('tools.php') + ); + echo '

'; + } +}); + +/** + * CORS-Header erlauben (falls nötig für den C#-Importer) + */ +add_filter('rest_pre_serve_request', function ($served, $result, $request) { + if (strpos($request->get_route(), 'custom/v1/') === 0) { + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, OPTIONS'); + header('Access-Control-Allow-Headers: Authorization, Content-Type'); + } + return $served; +}, 10, 3); + +add_action('rest_api_init', function () { + // OPTIONS-Requests für CORS erlauben + register_rest_route('custom/v1', '/cors', array( + 'methods' => 'OPTIONS', + 'callback' => function () { + return new WP_REST_Response(null, 200); + }, + 'permission_callback' => '__return_true', + )); +}); \ No newline at end of file diff --git a/WPPlugin/rbrExportRestApi.zip b/WPPlugin/rbrExportRestApi.zip new file mode 100644 index 0000000000000000000000000000000000000000..90445bf8bcd1ca0a4b56122b1fcebece53645009 GIT binary patch literal 3284 zcma)9XE+;-7LGk4O6*Ngs`jS#9yN=ItwfDjk+fEg*wm~oH7Y2zXHhk4&zdbkZPnp3 zN^9Nr{iO>sEF-3GJm+hDTsqOv^`mAT2wTOS?YYt8<9qWs>de|_)E~)KdB24s5zo&p>{C&K+95ED%#U~0be{9|?g20^;&`}V?kqA=N*}hYO<5L1@k!o! zB(N~}(NuJ47vcF*gcemHp+S@(6q%?h@SbM(kfh{=1akH31oqu8W>S60TYV37!|0__ zB?Jrk-qGwECd791Nb}#v<*vD&sLMHg7~gFkkkwUlxvg(wbI1Q7c^GURO`k#8CoV`= zO9~dwzRZs&ae9P|u??QDQ^1vWbIT{#taODL3J6Q`yz4fIdUOUA$d$@yIc<}kR)s?n zxM+ACKS~@aPRUQo|8A_xXoQSH6ylb+Jw^|hgZ1{PAmczJc?eohT2f+r7Q!-ZmhxKF879h0x_J4 zCha=+X7Q@qIa6H4=GCg*(Z6r>dxJy}#SNP(y}-zeVw;i#Q`)lVF{+7amU6jpVW(C0 z^*OMC9iE)O`huW}6MnhF+r5Fwjxod#kbSX{i=ysJyW2=vPw}QJH8U$%GohR{xeLF# zVw4yz(%WO{xLVx_W~M?*r{!-hc{&M*KX_lGd5gk_K}c4UAy>c>g-BFm#EI0wy3DR4 z^OD`J7&$fx#PUk_u`jU#(IVc9a5yFHZD-7bq}fqxS0rYyEq79kqks=g zQ*Gpe)5{;3IS`Vps)VXU>_g$SmpoMF>E!IsW-*Q(GX5ZpWnz52CX0%$D#v+9=FS|t z(JleZa@2)Laf_jKi6B4pGUmfrx!mG zL&aMO*_1zhlrVehcmq5$93qn#)q(}pAIoVbw{a^cLE57)O|(u90``=w#1sJ!wWCJSNTcVjjqX#XI$q?S3wIl9W zw|Y4T!SsrOMQ(CjNhG?{8xZ`cFm)$xt+Y)c_AI3puGv!iJts^5L%ATK5V2RHkSgcG z8Q%i~b3|1eFI@>}FuBezGZ7`7?(WdWu|2{ zu*xcsna#qCCm&C zrPY{b1^bCq@SRQ(FiGU}`UVojR7#|{eL0YhJtI>l;9x zG|bE>A<&TrAqqe4K|}fyiS2|d^R@l7u{EzLNd)bzd71Ae=CA*db+U{z5!jHp`;-DX z?&+x3HI9gO)q8TYe3%T2xl#ShD&S4h4x(; z?3*H;;Ds-OF}~uAH;H=Ic&qvkCgt%jeXU-R)`%ivt=?K|zAmW3&Bxl6eZ>Qbm0Y(= z@w?UgwF_2IpCY6hr#?;DzbBH9g0vh3kUmea4;7>{Gzwfys>uCXbC1o1|+rcZ1ED zA!J?;RbvfBDXv?Vt~2u-d{`pnwIYqeGSZ|sNBBJpUo34AwTg0y7T`ByicN1;@0$=u z7iBzc2j7xqw47qKs~INt;!03{(@NOaN!q|n;#t?W2h#Wwt*fI3K{$V9W+^4`+;aEy~RIe*$`|6Q6EVKXby)sZlcc(g|4)5Q^sx z2{{bZ;)p?3LG8DEUj6*>B9`EqJvcBP3SBN$uDHFl_zUfYtZ| zVaHhBI_6X*Q+GtmUN8)5G?%D6t`R;miyKq1-0jMKLig;RcavP5#hi2s8YPuro&j3j z#@cy9&qDaZRJNVu8_1(5`mhru91KXI#;;zqk!f3xxbM*K7IG{?w0Nit4d4q>Jxb1i zP>-~Pt+8zx$d2^7GIdVSY@C6L`Hj9Drh~|OZglo_5T*>uK@_!2%X1y-xEku==r2jf z_0lY&_=)gi&hTE&)VpicRnJwFjxZ7r*~PXE7}UgADaKN|jvpRIZs$RZwu5+0!qlPs z3^}zvqH<}i_s3rgQXEW!?WWcU?#S=%Fs_~}pZ-OjG zzSL2;Xu180V;D%g=-hi