using System.Text.Json; using System.Text.Json.Serialization; using System.Net; using System.Net.Http; using HtmlAgilityPack; using wp2ghost; // Die Web-Oberfläche (Projekt Wp2GhostUI) ruft dieselben Abläufe auf wie das Konsolenmenü [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Wp2GhostUI")] /// /// Der Importer: Einstellungen und alle Abläufe. Bedient wird er entweder über das /// Konsolenmenü (Main) oder über die Web-Oberfläche, die dieselben Methoden aufruft. /// Rückfragen laufen in beiden Fällen über Console.ReadLine/Write. /// class Importer { internal static string WpBaseUrl = "https://deine-domain.com"; internal static string OutputZipPath = "ghost_import.zip"; internal static int MaxPosts = -1; private static string ConfigPath = ""; internal static string BaseDir = ""; /// Zugangsdaten einer Ghost-Instanz. internal class GhostTarget { public string Name = ""; public string Url = ""; public string AdminApiKey = ""; public string ContentApiKey = ""; public bool IsConfigured => Url.Length > 0 && AdminApiKey.Length > 0; } /// /// Zwei Ghost-Instanzen, zwischen denen umgeschaltet werden kann. Gesendet, geprüft und /// abgeglichen wird immer mit der aktiven; Slug-Zuordnungen und hochgeladene Videos führt /// jede Instanz für sich. Das Archiv (ghostPosts/archiv/) ist für beide gemeinsam. /// internal static readonly GhostTarget[] GhostTargets = { new(), new() }; internal static int ActiveGhost { get; private set; } internal static GhostTarget Ghost => GhostTargets[ActiveGhost]; // Die Zugangsdaten der aktiven Instanz — so bleiben alle bisherigen Aufrufe unverändert internal static string GhostUrl { get => Ghost.Url; set => Ghost.Url = value; } internal static string GhostAdminApiKey { get => Ghost.AdminApiKey; set => Ghost.AdminApiKey = value; } internal static string GhostContentApiKey { get => Ghost.ContentApiKey; set => Ghost.ContentApiKey = value; } /// Anzeigename einer Instanz: der vergebene Name, sonst die Adresse, sonst "Ghost 1/2". internal static string GhostLabel(int index) { var target = GhostTargets[index]; if (target.Name.Length > 0) return target.Name; if (target.Url.Length > 0) return target.Url.Replace("https://", "").Replace("http://", "").TrimEnd('/'); return $"Ghost {index + 1}"; } /// /// Datei im Datenordner, die nur für die aktive Instanz gilt. Die erste Instanz behält die /// bisherigen Dateinamen, die zweite bekommt "_2" angehängt (z. B. slug_zuordnung_2.txt). /// internal static string InstanceFile(string fileName) => Path.Combine(BaseDir, ActiveGhost == 0 ? fileName : $"{Path.GetFileNameWithoutExtension(fileName)}_{ActiveGhost + 1}{Path.GetExtension(fileName)}"); /// Lädt die Einstellungen aus dem Datenordner und richtet alle Bausteine darauf ein. internal static void Initialize(string baseDir) { BaseDir = baseDir; ConfigPath = Path.Combine(BaseDir, "config.txt"); LoadConfig(); ImageManager.Init(BaseDir); MediaManager.Init(BaseDir); InternalLinkManager.Init(BaseDir); PostFetcher.Init(BaseDir); GhostExporter.Init(BaseDir); LinkDecisionManager.Init(BaseDir); ActivateGhost(); } /// Speichert geänderte Einstellungen und meldet Ghost mit den neuen Zugangsdaten an. internal static void ApplySettings() { SaveConfig(); ActivateGhost(); } /// Macht eine der beiden Ghost-Instanzen zur aktiven und merkt sich die Wahl. internal static void SwitchGhost(int index) { if (index < 0 || index >= GhostTargets.Length || index == ActiveGhost) return; ActiveGhost = index; SaveConfig(); ActivateGhost(); Console.WriteLine($"Aktive Ghost-Instanz: {GhostLabel(index)}"); } // Meldet Ghost mit den Zugangsdaten der aktiven Instanz an und lädt deren Slug-Zuordnungen private static void ActivateGhost() { GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); SlugAssignments.Init(InstanceFile("slug_zuordnung.txt")); } static async Task Main(string[] args) { Initialize(Directory.GetCurrentDirectory()); Console.OutputEncoding = System.Text.Encoding.UTF8; // Damit Emojis/Sonderzeichen beim manuellen Editieren eingegeben werden können if (!Console.IsInputRedirected) { try { Console.InputEncoding = System.Text.Encoding.Unicode; } catch { } } Console.Title = "WP2Ghost Converter"; 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 "9": ChooseGhost(); 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-Instanz : {ActiveGhost + 1} – {GhostLabel(ActiveGhost)}"); Console.WriteLine($"Ghost-URL : {(string.IsNullOrEmpty(GhostUrl) ? "(nicht konfiguriert)" : GhostUrl)}"); Console.WriteLine($"Ghost-Status : {(GhostApi.IsConfigured() ? "verbunden" : "nicht konfiguriert")}"); Console.WriteLine("-----------------------------------------"); 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(" 9 - Ghost-Instanz wechseln"); 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 values = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var line in File.ReadAllLines(ConfigPath)) { if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; var parts = line.Split('=', 2); if (parts.Length == 2) values[parts[0].Trim()] = parts[1].Trim(); } if (values.TryGetValue("wpBaseUrl", out string? wpBaseUrl)) WpBaseUrl = wpBaseUrl; if (values.TryGetValue("outputZipPath", out string? outputZipPath)) OutputZipPath = outputZipPath; if (values.TryGetValue("maxPosts", out string? maxPosts) && int.TryParse(maxPosts, out int max)) MaxPosts = max; for (int i = 0; i < GhostTargets.Length; i++) { string prefix = GhostConfigPrefix(i); var target = GhostTargets[i]; target.Name = values.GetValueOrDefault(prefix + "Name", ""); target.Url = values.GetValueOrDefault(prefix + "Url", ""); target.AdminApiKey = values.GetValueOrDefault(prefix + "AdminApiKey", ""); target.ContentApiKey = values.GetValueOrDefault(prefix + "ContentApiKey", ""); } if (values.TryGetValue("activeGhost", out string? active) && int.TryParse(active, out int number) && number >= 1 && number <= GhostTargets.Length) ActiveGhost = number - 1; } catch (Exception ex) { Console.WriteLine($"Fehler beim Laden der Konfiguration: {ex.Message}"); } } // Schlüssel in config.txt: die erste Instanz behält die bisherigen (ghostUrl ...), die zweite heißt ghost2Url ... private static string GhostConfigPrefix(int index) => index == 0 ? "ghost" : $"ghost{index + 1}"; static void SaveConfig() { var lines = new List { "# WP2Ghost Konfiguration", "# Ändere die Werte und starte das Programm neu", "", $"wpBaseUrl={WpBaseUrl}", $"outputZipPath={OutputZipPath}", $"maxPosts={MaxPosts}", "", "# Ghost API (optional - für direkten Push). Zwei Instanzen, activeGhost wählt die aktive (1 oder 2).", $"activeGhost={ActiveGhost + 1}", }; for (int i = 0; i < GhostTargets.Length; i++) { string prefix = GhostConfigPrefix(i); var target = GhostTargets[i]; lines.Add(""); lines.Add($"{prefix}Name={target.Name}"); lines.Add($"{prefix}Url={target.Url}"); lines.Add($"{prefix}AdminApiKey={target.AdminApiKey}"); if (!string.IsNullOrEmpty(target.ContentApiKey)) lines.Add($"{prefix}ContentApiKey={target.ContentApiKey}"); } File.WriteAllLines(ConfigPath, lines); } static void ChooseGhost() { Console.WriteLine(); Console.WriteLine("--- Ghost-Instanz wechseln ---"); for (int i = 0; i < GhostTargets.Length; i++) { string state = GhostTargets[i].IsConfigured ? GhostTargets[i].Url : "(nicht eingerichtet)"; Console.WriteLine($" {i + 1} - {GhostLabel(i)}{(i == ActiveGhost ? " [aktiv]" : "")} {state}"); } Console.Write("Auswahl (leer = unverändert): "); if (int.TryParse(Console.ReadLine()?.Trim(), out int number) && number >= 1 && number <= GhostTargets.Length) SwitchGhost(number - 1); if (!Ghost.IsConfigured) Console.WriteLine("Diese Instanz ist noch nicht eingerichtet: bitte Option 3 (Einstellungen) nutzen."); } static void ConfigureSettings() { Console.WriteLine(); 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) – Instanz {ActiveGhost + 1}, wechseln mit Option 9 ---"); Console.Write($"Name der Instanz [{Ghost.Name}]: "); input = Console.ReadLine()?.Trim(); if (!string.IsNullOrEmpty(input)) Ghost.Name = input; Console.Write($"Ghost-URL [{GhostUrl}]: "); input = Console.ReadLine()?.Trim(); if (!string.IsNullOrEmpty(input)) 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; ApplySettings(); 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($" Instanz : {ActiveGhost + 1} – {GhostLabel(ActiveGhost)}"); 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(" 9 - Untertag zu allen Posts mit einem bestimmten Tag hinzufügen"); Console.WriteLine(" 10 - Posts in Ghost prüfen und korrigieren (z. B. Postdatum)"); Console.WriteLine(" 0 - Zurück zum Hauptmenü"); Console.WriteLine("-----------------------------------------"); Console.Write("Auswahl: "); 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 => InternalLinkManager.Rewrite(HtmlGhostConverter.Convert(html), WpBaseUrl, GhostUrl).Html); break; case "7": await GhostUploadImagesAsync(); break; case "8": await GhostCreateOrUpdateFromJsonAsync(); break; case "9": await GhostAddTagToTaggedPostsAsync(); break; case "10": await PostChecker.RunAsync(BaseDir); 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); // Alle -Tags suchen var anchors = doc.DocumentNode.SelectNodes("//a[@href]"); if (anchors == null) return html; bool repaired = false; var candidates = new List<(string text, string href, HtmlNode node)>(); foreach (var anchor in anchors) { string href = anchor.GetAttributeValue("href", "").Trim(); if (string.IsNullOrEmpty(href)) continue; // Eindeutig reparierbare Adressen (z. B. von \"...\" umschlossen) stillschweigend korrigieren string? fixedHref = LinkChecker.TryRepair(HtmlEntity.DeEntitize(href)); if (fixedHref != null) { Console.WriteLine($" [Links] Adresse repariert: {fixedHref}"); anchor.SetAttributeValue("href", fixedHref); href = fixedHref; repaired = true; } // Links auf die eigene Ghost-Seite nicht prüfen: das Ziel wird evtl. erst später importiert if (!string.IsNullOrEmpty(GhostUrl) && href.StartsWith(GhostUrl.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) continue; candidates.Add((anchor.InnerText.Trim(), href, anchor)); } // Erreichbarkeit parallel prüfen (begrenzt, damit kein Server mit Anfragen überhäuft wird) using var throttle = new SemaphoreSlim(6); var reasons = await Task.WhenAll(candidates.Select(async c => { await throttle.WaitAsync(); try { return await LinkChecker.CheckAsync(HtmlEntity.DeEntitize(c.href)); } finally { throttle.Release(); } })); var invalidLinks = new List<(string text, string href, HtmlNode node, string reason)>(); for (int i = 0; i < candidates.Count; i++) { if (reasons[i] != null) invalidLinks.Add((candidates[i].text, candidates[i].href, candidates[i].node, reasons[i]!)); } if (invalidLinks.Count == 0) return repaired ? doc.DocumentNode.OuterHtml : html; // User über invaliden Links informieren und zur Korrektur auffordern Console.WriteLine($"\n {invalidLinks.Count} ungültiger Link(s) gefunden:"); for (int i = 0; i < invalidLinks.Count; i++) { Console.WriteLine($" [{i + 1}] {invalidLinks[i].reason} | Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}"); } Console.WriteLine("\nOptionen pro Link:"); Console.WriteLine(" d = Link löschen (Text bleibt, -Tag entfernt)"); Console.WriteLine(" e = Link bearbeiten (neue URL eingeben)"); Console.WriteLine(" k = Link so übernehmen (beibehalten)"); Console.WriteLine(" Enter = frühere Entscheidung für diesen Link (nur wenn es eine gibt)"); // Für jeden invaliden Link die User-Entscheidung einholen foreach (var (linkText, linkHref, anchorNode, linkReason) in invalidLinks) { // Für die Web-Oberfläche: um welchen Link es bei den folgenden Rückfragen geht LinkQuestion.Current = new LinkQuestion(linkText, HtmlEntity.DeEntitize(linkHref), linkReason); // 1. Memory: Frühere Entscheidung suchen und vorschlagen var previous = LinkDecisionManager.GetPreviousDecision(linkText, linkHref); 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. Grund nennen und Browser-Öffnen anbieten string searchQuery = Uri.EscapeDataString(linkText); string searchUrl = $"https://www.google.com/search?q={searchQuery}"; Console.WriteLine($" Problem : {linkReason}"); // Gibt es schon eine Entscheidung für diesen Link, ist sie der Default (Enter). // Ohne frühere Entscheidung MUSS der User eine treffen — Enter allein genügt dann nicht. string options = "k=beibehalten / d=löschen / e=bearbeiten / b=Browser öffnen"; if (previous != null) { string defaultLabel = previous.Decision switch { "delete" => "wie zuvor: löschen", "edit" => $"wie zuvor: ersetzen durch '{previous.NewUrl}'", _ => "wie zuvor: beibehalten" }; options = $"Enter={defaultLabel} / {options}"; } string decision = ""; string? newUrl = null; while (decision == "") { Console.Write($" Optionen: [{options}]: "); string? input = Console.ReadLine(); if (input == null) { // Keine Eingabe möglich (stdin geschlossen): nichts ändern und nichts merken, // damit beim nächsten Lauf erneut gefragt wird decision = previous != null ? "previous" : "unanswered"; break; } switch (input.Trim().ToLowerInvariant()) { case "b": Console.WriteLine($" -> Öffne Browser mit Suche nach: \"{linkText}\""); OpenBrowser(searchUrl); break; case "": case "v": if (previous != null) decision = "previous"; else Console.WriteLine(" Für diesen Link gibt es noch keine Entscheidung – bitte k, d oder e wählen."); break; case "k": decision = "keep"; break; case "d": decision = "delete"; break; case "e": Console.Write(" Neue URL eingeben: "); newUrl = Console.ReadLine()?.Trim(); if (!string.IsNullOrEmpty(newUrl)) decision = "edit"; else Console.WriteLine(" Keine URL eingegeben – bitte erneut wählen."); break; default: Console.WriteLine(" Ungültige Eingabe – bitte k, d oder e wählen."); break; } } switch (decision) { case "previous": ApplyDecision(anchorNode, previous!, linkText, linkHref, ref html, doc); Console.WriteLine($" -> Frühere Entscheidung angewendet: {previous!.Decision}"); break; case "delete": // Link löschen: -Tag entfernen, Text erhalten anchorNode.ParentNode?.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode); Console.WriteLine($" -> Link gelöscht"); LinkDecisionManager.SaveDecision(linkText, linkHref, "delete"); break; case "edit": anchorNode.SetAttributeValue("href", newUrl!); Console.WriteLine($" -> Auf '{newUrl}' aktualisiert"); LinkDecisionManager.SaveDecision(linkText, linkHref, "edit", newUrl); break; case "keep": Console.WriteLine($" -> Beibehalten"); LinkDecisionManager.SaveDecision(linkText, linkHref, "keep"); break; default: Console.WriteLine($" -> Keine Eingabe möglich, Link bleibt unverändert (wird beim nächsten Lauf erneut gefragt)"); break; } } LinkQuestion.Current = null; return doc.DocumentNode.OuterHtml; } // ========================================================================= // Problemfälle im Text: mehrere "?" hintereinander // Entstehen, wenn Zeichen (Emojis, griechische/arabische Schrift ...) bei einer // früheren Konvertierung verloren gingen. Pro Fundstelle entscheidet der User: // löschen, manuell editieren oder beibehalten. Entscheidungen werden gemerkt. // ========================================================================= // "??" und längere Folgen, auch über Leer-/Satzzeichen hinweg ("???? ??? ?????, ????") private static readonly System.Text.RegularExpressions.Regex QuestionMarkRunRegex = new( @"\?{2,}(?:[\s,.;:\-/]*\?+)*", System.Text.RegularExpressions.RegexOptions.Compiled); // Kennung, unter der Text-Entscheidungen im Link-Memory abgelegt werden (statt einer URL) private const string TextProblemMarker = "text:fragezeichen"; private static string ReviewQuestionMarksInHtml(string html) { if (string.IsNullOrEmpty(html) || !QuestionMarkRunRegex.IsMatch(html)) return html; var doc = new HtmlDocument(); doc.LoadHtml(html); // Nur sichtbarer Text — keine URLs/Attribute, kein Code var textNodes = doc.DocumentNode.SelectNodes("//text()[not(ancestor::script) and not(ancestor::style) and not(ancestor::code) and not(ancestor::pre)]"); if (textNodes == null) return html; bool changed = false; foreach (var node in textNodes.ToList()) { string original = node.InnerHtml; if (!QuestionMarkRunRegex.IsMatch(original)) continue; string reviewed = ReviewQuestionMarksInText(original, "Text", htmlEncoded: true); if (reviewed == original) continue; node.ParentNode!.ReplaceChild(doc.CreateTextNode(reviewed), node); changed = true; } return changed ? doc.DocumentNode.OuterHtml : html; } private static string ReviewQuestionMarksInText(string text, string where, bool htmlEncoded = false) { if (string.IsNullOrEmpty(text)) return text; return QuestionMarkRunRegex.Replace(text, match => { // Umgebung der Fundstelle anzeigen, die "?"-Folge hervorgehoben int from = Math.Max(0, match.Index - 50); int to = Math.Min(text.Length, match.Index + match.Length + 30); string before = text.Substring(from, match.Index - from); string after = text.Substring(match.Index + match.Length, to - match.Index - match.Length); string context = WebUtility.HtmlDecode($"{before}»{match.Value}«{after}").Trim(); var previous = LinkDecisionManager.GetPreviousDecision(context, TextProblemMarker); if (previous != null) { string result = ApplyTextDecision(previous.Decision, previous.NewUrl, match.Value, htmlEncoded); Console.WriteLine($" [Text] Frühere Entscheidung angewendet ({previous.Decision}): {context}"); return result; } Console.WriteLine(); Console.WriteLine($" [Problemfall {where}] Mehrere '?' – vermutlich verlorene Zeichen:"); Console.WriteLine($" {context}"); Console.Write(" Optionen: [l=löschen / e=manuell editieren / Enter=beibehalten]: "); string? choice = Console.ReadLine()?.Trim().ToLowerInvariant(); if (choice == "l") { LinkDecisionManager.SaveDecision(context, TextProblemMarker, "delete"); Console.WriteLine(" -> gelöscht"); return ApplyTextDecision("delete", null, match.Value, htmlEncoded); } if (choice == "e") { Console.Write($" Ersatz für »{match.Value}« eingeben: "); string? replacement = Console.ReadLine(); if (!string.IsNullOrEmpty(replacement)) { LinkDecisionManager.SaveDecision(context, TextProblemMarker, "edit", replacement); Console.WriteLine($" -> ersetzt durch '{replacement}'"); return ApplyTextDecision("edit", replacement, match.Value, htmlEncoded); } } LinkDecisionManager.SaveDecision(context, TextProblemMarker, "keep"); Console.WriteLine(" -> beibehalten"); return match.Value; }); } private static string ApplyTextDecision(string decision, string? replacement, string original, bool htmlEncoded) => decision switch { "delete" => "", "edit" when !string.IsNullOrEmpty(replacement) => htmlEncoded ? WebUtility.HtmlEncode(replacement) : replacement, _ => original }; // ========================================================================= // Hilfsmethoden für Link-Entscheidungen // ========================================================================= /// /// 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}"); } } /// /// Hängt an alle Posts in Ghost, die einen bestimmten Tag tragen, einen weiteren Tag an /// (z. B. alle "Zypern"-Posts zusätzlich nach "Unsere Reisen"). Der neue Tag wird angelegt, /// falls es ihn noch nicht gibt, und steht hinter den vorhandenen Tags — der primäre Tag /// eines Posts ändert sich nicht. /// static async Task GhostAddTagToTaggedPostsAsync() { try { var allTags = await GhostApi.BrowseTagsWithCountsAsync(); if (allTags.Count == 0) { Console.WriteLine(" In Ghost gibt es noch keine Tags."); return; } Console.WriteLine($" {allTags.Count} Tags in Ghost (Anzahl Posts):"); foreach (var tag in allTags) Console.WriteLine($" {tag.Name} ({tag.Count?.Posts ?? 0})"); Console.Write("Posts mit welchem Tag? (Name, leer = Abbruch): "); string sourceName = Console.ReadLine()?.Trim() ?? ""; if (sourceName.Length == 0) return; var sourceTag = allTags.FirstOrDefault(t => string.Equals(t.Name, sourceName, StringComparison.OrdinalIgnoreCase)) ?? allTags.FirstOrDefault(t => string.Equals(t.Slug, sourceName, StringComparison.OrdinalIgnoreCase)); if (sourceTag?.Slug == null) { Console.WriteLine($" Tag '{sourceName}' gibt es in Ghost nicht."); return; } Console.Write($"Welcher Untertag soll zu allen '{sourceTag.Name}'-Posts hinzugefügt werden? (leer = Abbruch): "); string newName = Console.ReadLine()?.Trim() ?? ""; if (newName.Length == 0) return; if (string.Equals(newName, sourceTag.Name, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine(" Das ist derselbe Tag."); return; } var posts = await GhostApi.BrowsePostsByTagAsync(sourceTag.Slug); var missing = posts.Where(p => !(p.Tags ?? new()).Any(t => string.Equals(t.Name, newName, StringComparison.OrdinalIgnoreCase))).ToList(); Console.WriteLine($" {posts.Count} Post(s) mit '{sourceTag.Name}', davon {posts.Count - missing.Count} bereits mit '{newName}'."); if (missing.Count == 0) { Console.WriteLine(" Nichts zu tun."); return; } bool exists = allTags.Any(t => string.Equals(t.Name, newName, StringComparison.OrdinalIgnoreCase)); Console.Write($" '{newName}'{(exists ? "" : " (wird neu angelegt)")} zu {missing.Count} Post(s) hinzufügen? (j/n): "); if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") return; var newTag = (await GhostApi.EnsureTagsAsync(new List { new WpCategory { Name = newName } })).FirstOrDefault(); if (newTag?.Id == null) { Console.WriteLine($" [Fehler] Tag '{newName}' konnte nicht angelegt werden."); return; } int done = 0; foreach (var post in missing) { Console.Write($" [{done + 1}/{missing.Count}] {post.Title}..."); // Vorhandene Tags in ihrer Reihenfolge behalten, den neuen hinten anhängen var tagIds = (post.Tags ?? new()).Where(t => t.Id != null).Select(t => t.Id!).Append(newTag.Id).Distinct(); bool ok = await GhostApi.SetPostTagsAsync(post.Id!, post.UpdatedAt ?? "", tagIds); Console.WriteLine(ok ? " OK" : " FEHLER"); if (!ok) { Console.WriteLine($" ABBRUCH wegen eines Fehlers. {done} von {missing.Count} Post(s) geändert."); return; } done++; } Console.WriteLine($" '{newTag.Name}' zu {done} Post(s) hinzugefügt."); } catch (Exception ex) { Console.WriteLine($" [Fehler] {ex.Message}"); } } /// /// Sendet einen Post oder einen Bereich von Posts (Von–Bis) aus ghostPosts/ an Ghost. /// Beim ersten Fehler wird abgebrochen. Danach können die erfolgreich gesendeten Posts /// archiviert werden (ghostPosts/archiv/), damit sie nicht mehr in der Auswahlliste stehen. /// static async Task GhostCreateOrUpdateFromJsonAsync() { string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); if (!Directory.Exists(ghostPostsDir)) { Console.WriteLine("Verzeichnis 'ghostPosts' nicht gefunden."); return; } // Nach ID sortiert (numerisch), damit Von–Bis der angezeigten Reihenfolge entspricht var available = Directory.GetFiles(ghostPostsDir, "*.json") .Select(f => (Path: f, Name: Path.GetFileNameWithoutExtension(f))) .Where(f => int.TryParse(f.Name, out _)) .Select(f => (f.Path, Id: int.Parse(f.Name))) .OrderBy(f => f.Id) .ToList(); if (available.Count == 0) { Console.WriteLine("Keine JSON-Dateien in ghostPosts/ vorhanden."); return; } Console.WriteLine($" {available.Count} verfügbare Posts:"); foreach (var f in available) { Console.Write($" {f.Id} - "); try { var preview = JsonSerializer.Deserialize(File.ReadAllText(f.Path)); Console.WriteLine(preview?.Title ?? "(kein Titel)"); } catch { Console.WriteLine("(Fehler beim Lesen)"); } } Console.Write("Von ID (leer = Abbruch): "); if (!int.TryParse(Console.ReadLine()?.Trim(), out int fromId)) return; Console.Write($"Bis ID (leer = nur {fromId}): "); string? toInput = Console.ReadLine()?.Trim(); int toId = fromId; if (!string.IsNullOrEmpty(toInput) && !int.TryParse(toInput, out toId)) { Console.WriteLine("Ungültige Bis-ID."); return; } if (toId < fromId) (fromId, toId) = (toId, fromId); var selected = available.Where(f => f.Id >= fromId && f.Id <= toId).ToList(); if (selected.Count == 0) { Console.WriteLine($"Keine Posts im Bereich {fromId}–{toId} gefunden."); return; } if (selected.Count > 1) { Console.Write($" {selected.Count} Posts ({selected[0].Id}–{selected[^1].Id}) an Ghost senden? (j/n): "); if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") return; } await SendPostsAsync(selected); } /// /// Sendet die übergebenen Posts der Reihe nach an die aktive Ghost-Instanz, bricht beim ersten /// Fehler ab und bietet danach an, die erfolgreich gesendeten zu archivieren. /// internal static async Task SendPostsAsync(List<(string Path, int Id)> selected) { Console.WriteLine($"Ziel: {GhostLabel(ActiveGhost)} ({GhostUrl})"); string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); var succeeded = new List<(string Path, int Id)>(); (string Path, int Id)? failed = null; for (int i = 0; i < selected.Count; i++) { Console.WriteLine(); Console.WriteLine($"=== [{i + 1}/{selected.Count}] Post {selected[i].Id} ==="); bool ok; try { ok = await ImportPostFromJsonAsync(selected[i].Path); } catch (Exception ex) { Console.WriteLine($" [Fehler] {ex.Message}"); ok = false; } if (!ok) { failed = selected[i]; break; } succeeded.Add(selected[i]); } Console.WriteLine(); Console.WriteLine("========================================="); Console.WriteLine($" Erfolgreich: {succeeded.Count} von {selected.Count}"); if (failed.HasValue) { int notProcessed = selected.Count - succeeded.Count - 1; Console.WriteLine($" ABBRUCH bei Post {failed.Value.Id} wegen eines Fehlers."); if (notProcessed > 0) Console.WriteLine($" Nicht mehr verarbeitet: {notProcessed} Post(s) ab ID {selected[succeeded.Count + 1].Id}"); } Console.WriteLine("========================================="); if (succeeded.Count == 0) return; Console.Write($" {succeeded.Count} erfolgreich gesendete(n) Post(s) archivieren? (j/n): "); if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") return; // Archivierte Dateien liegen in einem Unterordner und erscheinen nicht mehr in der Auswahlliste string archiveDir = Path.Combine(ghostPostsDir, "archiv"); Directory.CreateDirectory(archiveDir); foreach (var post in succeeded) File.Move(post.Path, Path.Combine(archiveDir, Path.GetFileName(post.Path)), overwrite: true); Console.WriteLine($" {succeeded.Count} Post(s) nach ghostPosts/archiv/ verschoben."); } /// /// Sendet einen einzelnen Post an Ghost. Liefert false, sobald etwas schiefgeht — /// ein Post mit fehlenden Bildern oder Videos wird dann gar nicht erst angelegt. /// // Zuletzt eingegebene Untertags — bei einem Von–Bis-Export gehören oft mehrere Posts zum selben Thema private static string _lastSubTags = ""; private static async Task ImportPostFromJsonAsync(string jsonPath) { GhostPostFile? postFile; try { postFile = JsonSerializer.Deserialize(File.ReadAllText(jsonPath)); } catch (Exception ex) { Console.WriteLine($" [Fehler] JSON konnte nicht gelesen werden: {ex.Message}"); return false; } if (postFile == null) { Console.WriteLine(" [Fehler] JSON konnte nicht gelesen werden."); return false; } // Auch ältere JSON-Dateien enthalten noch prozentkodierte WordPress-Slugs postFile.Slug = SlugHelper.Clean(postFile.Slug, $"post-{postFile.WpId}"); // Die Kategorie "Allgemein" wird nie übernommen — weder als Tag noch als Schlagwort postFile.Categories = postFile.Categories.Where(c => !CategoryRules.IsIgnored(c)).ToList(); Console.WriteLine($" Post: {postFile.Title}"); Console.WriteLine($" Slug: {postFile.Slug}"); // Immer das Veröffentlichungsdatum des WordPress-Beitrags verwenden. Ohne Datum würde // Ghost den Zeitpunkt des Imports eintragen — dann lieber abbrechen. string? publishedAt = DateHelper.ToGhostUtc(postFile.PublishedAt ?? postFile.CreatedAt); if (publishedAt == null) { Console.WriteLine(" [Fehler] Kein Veröffentlichungsdatum im JSON – Beitrag bitte neu abrufen."); return false; } Console.WriteLine($" Datum: {DateTimeOffset.Parse(publishedAt).ToLocalTime():dd.MM.yyyy HH:mm}"); Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); Console.WriteLine($" Schlagwörter: {postFile.Tags.Count}"); Console.WriteLine($" Kommentare: {CommentBlockBuilder.CountRealComments(postFile.Comments)} (ohne Pingbacks/Reaktionen)"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); // 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen var ghostUrlMap = new Dictionary(); int errors = 0; string html = postFile.Html ?? ""; // WordPress-Kommentare als Block ans Beitragsende (bei älteren JSON-Dateien fehlt er noch) html = CommentBlockBuilder.Append(html, postFile.Comments); // Links auf eigene WordPress-Beiträge auf die Ghost-Adresse umschreiben var internalLinks = InternalLinkManager.Rewrite(html, WpBaseUrl, GhostUrl); html = internalLinks.Html; if (internalLinks.Replaced > 0) Console.WriteLine($" [Links] {internalLinks.Replaced} interne(r) Link(s) auf Ghost umgeschrieben"); foreach (string open in internalLinks.Unresolved) Console.WriteLine($" [Links] Interner Link, Ziel (noch) nicht abgerufen – bleibt auf WordPress: {open}"); html = await ValidateAndFixLinksAsync(html); // Problemfälle im Text melden: mehrere "?" aus fehlerhafter Zeichen-Konvertierung postFile.Title = ReviewQuestionMarksInText(postFile.Title ?? "", "Titel"); html = ReviewQuestionMarksInHtml(html); if (postFile.ImageFiles.Count > 0) { Console.WriteLine($" [Bilder] Lade {postFile.ImageFiles.Count} Bilder hoch..."); foreach (string relPath in postFile.ImageFiles) { string localPath = Path.Combine(BaseDir, "images", relPath); if (!File.Exists(localPath)) { Console.WriteLine($" [Fehler] {relPath} (nicht lokal)"); errors++; 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"); errors++; } } } // 1b. Videos hochladen foreach (string relPath in postFile.MediaFiles) { string placeholder = MediaManager.GhostMediaPrefix + relPath; string localPath = MediaManager.GetLocalPath(relPath); // In diese Instanz bereits hochgeladen und dort noch vorhanden → nicht erneut übertragen string? url = MediaManager.GetUploadedUrl(relPath, GhostUrl); if (url != null && await GhostApi.ImageExistsAsync(url)) { Console.WriteLine($" [Video] {Path.GetFileName(localPath)} existiert bereits in Ghost"); ghostUrlMap[placeholder] = url; continue; } if (!File.Exists(localPath)) { Console.WriteLine($" [Fehler] Video {relPath} (nicht lokal)"); errors++; continue; } Console.Write($" [Video-Upload] {Path.GetFileName(localPath)} ({new FileInfo(localPath).Length / 1048576.0:0.0} MB)..."); url = await GhostApi.UploadMediaAsync(localPath); if (url != null) { Console.WriteLine($" -> {url}"); MediaManager.SetUploadedUrl(relPath, url); ghostUrlMap[placeholder] = url; } else { Console.WriteLine(" FEHLER"); errors++; } } // 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; else errors++; } else { Console.WriteLine($" [Fehler] Featured Image nicht lokal: {postFile.FeatureImage}"); errors++; } } // 3. __GHOST_URL__ im HTML durch echte URLs ersetzen foreach (var kvp in ghostUrlMap) html = html.Replace(kvp.Key, kvp.Value); // Übrig gebliebene Platzhalter wären in Ghost kaputte Bilder/Videos if (html.Contains("__GHOST_URL__")) { int remaining = System.Text.RegularExpressions.Regex.Matches(html, "__GHOST_URL__").Count; Console.WriteLine($" [Fehler] {remaining} nicht ersetzte __GHOST_URL__-Platzhalter im HTML"); errors++; } if (errors > 0) { Console.WriteLine($" [Fehler] {errors} Problem(e) bei Bildern/Videos – der Post wird nicht an Ghost gesendet."); return false; } // Bilder, die noch von WordPress geladen würden → Post neu abrufen (Hauptmenü 5, dann 1) int wpImages = System.Text.RegularExpressions.Regex.Matches(html, @"]*?\bsrc=[""'][^""']*/wp-content/uploads/", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count; if (wpImages > 0) Console.WriteLine($" [Warnung] {wpImages} Bild(er) im HTML zeigen noch auf WordPress (/wp-content/uploads/)"); // 4. Kategorien als Ghost-Tags erstellen/zuordnen. // Der erste Tag ist in Ghost der primäre, alle weiteren sind sekundär. 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}"); } int catIdx; while (true) { Console.Write(" Welche ist die primäre Kategorie? (Nummer): "); string? catChoice = Console.ReadLine(); if (int.TryParse(catChoice?.Trim(), out catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count) break; if (catChoice == null) { catIdx = 1; break; } // keine Eingabe möglich (stdin geschlossen) Console.WriteLine($" Bitte eine Zahl von 1 bis {postFile.Categories.Count} eingeben."); } // Gewählte Kategorie an erste Stelle setzen, alle anderen folgen als sekundäre var primary = postFile.Categories[catIdx - 1]; allTagSources.Add(primary); allTagSources.AddRange(postFile.Categories.Where((_, idx) => idx != catIdx - 1)); Console.WriteLine($" -> Primär: {primary.Name}"); Console.WriteLine($" -> Sekundär: {string.Join(", ", allTagSources.Skip(1).Select(c => c.Name))}"); } else if (postFile.Categories.Count == 1) { allTagSources.AddRange(postFile.Categories); } // Untertags: zusätzliche Tags, die der User vor dem Export vergibt. Sie stehen hinter den // Kategorien (sind also nie der primäre Tag, solange es eine Kategorie gibt) und werden // in Ghost angelegt, falls es sie noch nicht gibt. Console.WriteLine(); if (allTagSources.Count > 0) Console.WriteLine($" [Tags] Bisher: {string.Join(", ", allTagSources.Select(c => WebUtility.HtmlDecode(c.Name ?? "")))}"); string again = _lastSubTags.Length > 0 ? $", + = wie zuletzt: {_lastSubTags}" : ""; Console.Write($" Untertag hinzufügen (mehrere mit Komma, leer = keiner{again}): "); string subTagInput = Console.ReadLine()?.Trim() ?? ""; if (subTagInput == "+") subTagInput = _lastSubTags; if (subTagInput.Length > 0) { _lastSubTags = subTagInput; var known = allTagSources.Select(c => WebUtility.HtmlDecode(c.Name ?? "").Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (string name in subTagInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (known.Add(name)) allTagSources.Add(new WpCategory { Name = name }); } } if (allTagSources.Count > 0) { Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Tag(s)..."); tags = await GhostApi.EnsureTagsAsync(allTagSources); // Jede Kategorie mit Namen muss als Tag vorhanden sein int expectedTags = allTagSources .Select(c => WebUtility.HtmlDecode(c.Name ?? "").Trim().ToLowerInvariant()) .Where(n => n.Length > 0).Distinct().Count(); if (tags.Count < expectedTags) { Console.WriteLine($" [Fehler] Nur {tags.Count} von {expectedTags} Tags konnten angelegt/zugeordnet werden."); return false; } } // 4b. Schlagwörter (WP-Tags + SEO-Keywords) ohne Nachfrage als var keywords = postFile.Tags.Select(t => t.Name ?? "") .Concat((postFile.Keywords ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)) .Where(k => !CategoryRules.IsIgnored(k)) .ToList(); // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert // Hat der User diesem WordPress-Beitrag einen abweichenden Ghost-Slug zugeordnet // (Prüffunktion), wird dort gesucht — sonst entstünde ein zweiter Post. string lookupSlug = SlugAssignments.GetGhostSlug(postFile.WpId) ?? postFile.Slug ?? ""; Console.Write(" Prüfe Ghost..."); var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{lookupSlug}", 1); var existing = existingPosts.FirstOrDefault(); GhostAdminPost? result = null; if (existing != null) { Console.WriteLine($" vorhanden (ID: {existing.Id})"); string? head = GhostApi.BuildKeywordsHead(existing.CodeinjectionHead, keywords); if (head != null) Console.WriteLine(" [Meta] Schlagwörter als gesetzt"); Console.Write(" Aktualisiere..."); result = await GhostApi.UpdatePostExtendedAsync( existing.Id!, postFile.Title ?? "", html, existing.UpdatedAt ?? "", postFile.Status ?? "published", featureImageUrl, tags, head, publishedAt); Console.WriteLine(result != null ? " OK" : " FEHLER"); } else { Console.WriteLine(" nicht vorhanden"); string? head = GhostApi.BuildKeywordsHead(null, keywords); if (head != null) Console.WriteLine(" [Meta] Schlagwörter als gesetzt"); Console.Write(" Erstelle neuen Post..."); result = await GhostApi.CreatePostExtendedAsync( postFile.Title ?? "", html, postFile.Status ?? "published", postFile.Slug, featureImageUrl, tags, head, publishedAt); Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER"); } return result != null; } }