using System.Text.Json; using System.Text.Json.Serialization; using System.Net; using System.Net.Http; using HtmlAgilityPack; using wp2ghost; class Program { private static string WpBaseUrl = "https://deine-domain.com"; internal static string OutputZipPath = "ghost_import.zip"; private static int MaxPosts = -1; private static string ConfigPath = ""; private static string BaseDir = ""; private static string GhostUrl = ""; private static string GhostAdminApiKey = ""; private static string GhostContentApiKey = ""; 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); LinkDecisionManager.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 "8": LinkDecisionManager.ShowInfo(); break; case "0": ImageManager.SaveCache(); LinkDecisionManager.Save(); 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(" 8 - Gespeicherte Link-Entscheidungen anzeigen"); 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(); // Links validieren und ggf. korrigieren html = await ValidateAndFixLinksAsync(html); 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}"); } // ========================================================================= // HTML-Links validieren und bei Bedarf korrigieren // ========================================================================= private static async Task ValidateAndFixLinksAsync(string? html) { if (string.IsNullOrWhiteSpace(html)) return html ?? ""; var doc = new HtmlDocument(); doc.LoadHtml(html); var invalidLinks = new List<(string text, string href, HtmlNode node)>(); // Alle -Tags suchen var anchors = doc.DocumentNode.SelectNodes("//a[@href]"); if (anchors == null) return html; foreach (var anchor in anchors) { string href = anchor.GetAttributeValue("href", "").Trim(); string text = anchor.InnerText.Trim(); if (string.IsNullOrEmpty(href)) continue; // Prüfe, ob URL valide ist (inkl. Reichbarkeitsprüfung) if (!await IsValidUrlAsync(href, checkReachability: true)) { invalidLinks.Add((text, href, anchor)); } } if (invalidLinks.Count == 0) return html; // User über invaliden Links informieren und zur Korrektur auffordern Console.WriteLine($"\n {invalidLinks.Count} ungültiger Link(s) gefunden:"); for (int i = 0; i < invalidLinks.Count; i++) { Console.WriteLine($" [{i + 1}] Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}"); } Console.WriteLine("\nOptionen pro Link:"); Console.WriteLine(" d = Link löschen (Text bleibt, -Tag entfernt)"); Console.WriteLine(" e = Link bearbeiten (neue URL eingeben)"); Console.WriteLine(" Enter = Link so übernehmen (beibehalten)"); // Für jeden invaliden Link die User-Entscheidung einholen foreach (var (linkText, linkHref, anchorNode) in invalidLinks) { // 1. Memory: Frühere Entscheidung suchen und vorschlagen var previous = LinkDecisionManager.GetPreviousDecision(linkText, linkHref); Console.WriteLine($"\n ---------------------------------------------"); Console.WriteLine($" Link-Text : \"{linkText}\""); Console.WriteLine($" Aktueller Link : {linkHref}"); if (previous != null) { string prevInfo = previous.Decision switch { "delete" => "LÖSCHEN", "edit" => $"ERSETZEN durch '{previous.NewUrl}'", _ => "BEIBEHALTEN" }; Console.WriteLine($" [Memory] Frühere Entscheidung: {prevInfo} (vom {previous.Timestamp:dd.MM.yyyy HH:mm})"); } // 2. Browser-Öffnen anbieten (da Link nicht erreichbar ist) string searchQuery = Uri.EscapeDataString(linkText); string searchUrl = $"https://www.google.com/search?q={searchQuery}"; Console.WriteLine($" [Suche] Fallstrick: Link nicht erreichbar. Browser-Suche verfügbar."); Console.Write($" Optionen: [Enter=übernehmen" + (previous != null ? $" / v=Vorschlag '{previous.Decision}' anwenden" : "") + " / d=löschen / e=bearbeiten / b=Browser öffnen]: "); string? choice = Console.ReadLine()?.Trim()?.ToLowerInvariant(); // Browser öffnen if (choice == "b") { Console.WriteLine($" -> Öffne Browser mit Suche nach: \"{linkText}\""); OpenBrowser(searchUrl); // Nach dem Browser-Öffnen erneut nach der Entscheidung fragen Console.Write($" Jetzt entscheiden? [Enter=übernehmen / d=löschen / e=bearbeiten]: "); choice = Console.ReadLine()?.Trim()?.ToLowerInvariant(); } // Memory-Vorschlag anwenden if (choice == "v" && previous != null) { ApplyDecision(anchorNode, previous, linkText, linkHref, ref html, doc); Console.WriteLine($" -> Frühere Entscheidung angewendet: {previous.Decision}"); continue; } if (choice == "d") { // Link löschen: -Tag entfernen, Text erhalten var parent = anchorNode.ParentNode; if (parent != null) { parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); } Console.WriteLine($" -> Link gelöscht"); LinkDecisionManager.SaveDecision(linkText, linkHref, "delete"); } else if (choice == "e") { // Link bearbeiten: neue URL eingeben Console.Write(" Neue URL eingeben: "); string? newUrl = Console.ReadLine()?.Trim(); if (!string.IsNullOrEmpty(newUrl)) { anchorNode.SetAttributeValue("href", newUrl); Console.WriteLine($" -> Auf '{newUrl}' aktualisiert"); LinkDecisionManager.SaveDecision(linkText, linkHref, "edit", newUrl); } else { Console.WriteLine($" -> Beibehalten"); LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); } } else { Console.WriteLine($" -> Beibehalten"); LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); } } return doc.DocumentNode.OuterHtml; } // ========================================================================= // Hilfsmethoden für Link-Entscheidungen // ========================================================================= /// /// Wendet eine gespeicherte (frühere) Entscheidung auf den Link an. /// private static void ApplyDecision(HtmlNode anchorNode, LinkDecision decision, string linkText, string linkHref, ref string? html, HtmlDocument doc) { if (decision.Decision == "delete") { var parent = anchorNode.ParentNode; if (parent != null) { parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); } } else if (decision.Decision == "edit" && !string.IsNullOrEmpty(decision.NewUrl)) { anchorNode.SetAttributeValue("href", decision.NewUrl); } // "keep" = nichts tun } /// /// Öffnet die Standard-Browser-Anwendung mit der angegebenen URL. /// private static void OpenBrowser(string url) { try { var psi = new System.Diagnostics.ProcessStartInfo { FileName = url, UseShellExecute = true }; System.Diagnostics.Process.Start(psi); } catch (Exception ex) { Console.WriteLine($" [Browser-Fehler] Konnte Browser nicht öffnen: {ex.Message}"); } } private static async Task IsValidUrlAsync(string url, bool checkReachability = false) { if (string.IsNullOrWhiteSpace(url)) return false; // Relative URLs (beginnen mit / oder sind Pfade) sind für interne Links gültig if (url.StartsWith("/") || !url.Contains(":")) { if (checkReachability) { /* relative links are considered valid regardless */ } return true; } // Prüfe auf gültiges URI-Format mit http/https Schemes try { var uri = new Uri(url, UriKind.Absolute); if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return false; // Optionale Reichbarkeitsprüfung if (checkReachability) { using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; try { var request = new HttpRequestMessage(HttpMethod.Head, uri); var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); return (int)response.StatusCode < 400; // 2xx oder 3xx = erreichbar } catch (HttpRequestException) { // Verbindungsfehler, DNS-Failure, Timeout etc. return false; } catch (TaskCanceledException) { // Timeout return false; } } return true; } catch { return false; } } // Synchronen Wrapper für die bestehende Validierung private static bool IsValidUrl(string url) { return IsValidUrlAsync(url, false).Result; } 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($" Tags: {postFile.Tags.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 ?? ""; html = await ValidateAndFixLinksAsync(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 (Kategorien + Tags) var tags = new List(); var allTagSources = new List(); // Bei mehreren Kategorien: primäre Kategorie abfragen if (postFile.Categories.Count > 1) { Console.WriteLine(); Console.WriteLine(" [Kategorien] Mehrere Kategorien gefunden:"); for (int i = 0; i < postFile.Categories.Count; i++) { Console.WriteLine($" [{i + 1}] {postFile.Categories[i].Name}"); } Console.Write(" Relevante Kategorie (Nummer, Enter = erste): "); string? catChoice = Console.ReadLine()?.Trim(); if (int.TryParse(catChoice, out int catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count) { // Gewählte Kategorie an erste Stelle setzen var primary = postFile.Categories[catIdx - 1]; allTagSources.Add(primary); allTagSources.AddRange(postFile.Categories.Where((_, idx) => idx != catIdx - 1)); Console.WriteLine($" -> Primäre Kategorie: {primary.Name}"); } else { allTagSources.AddRange(postFile.Categories); Console.WriteLine($" -> Erste Kategorie: {postFile.Categories[0].Name}"); } } else if (postFile.Categories.Count == 1) { allTagSources.AddRange(postFile.Categories); } if (postFile.Tags.Count > 0) allTagSources.AddRange(postFile.Tags); if (allTagSources.Count > 0) { Console.WriteLine($" [Tags] Verarbeite {postFile.Categories.Count} Kategorien + {postFile.Tags.Count} Tags..."); tags = await GhostApi.EnsureTagsAsync(allTagSources); } // 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))}..."); } } } } } }