Importer - Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre abgefragt, die übrigen folgen als sekundäre Tags - Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld) - Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert Bilder - Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden zusammengefasst, einmal geladen und im HTML alle umgeschrieben - src bei Einzelbildern setzen, srcset/sizes entfernen - Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig - ZIP-Export enthält Cache-Bilder und feature_image - Warnung bei Bild-URLs, die weiter auf WordPress zeigen - Maximale Bildgröße 2048 px Plugin / GhostImageManager - Plugin liefert primäre Kategorie - Kategorie- und Keyword-Felder samt Filter im Image Manager Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
926 lines
36 KiB
C#
926 lines
36 KiB
C#
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<string>
|
|
{
|
|
"# 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<string> 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 <a>-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, <a>-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: <a>-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
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Wendet eine gespeicherte (frühere) Entscheidung auf den Link an.
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Öffnet die Standard-Browser-Anwendung mit der angegebenen URL.
|
|
/// </summary>
|
|
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<bool> 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<GhostPostFile>(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<GhostPostFile>(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($" Schlagwörter: {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, string>();
|
|
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");
|
|
}
|
|
|
|
// Bilder, die noch von WordPress geladen würden → Post neu abrufen (Hauptmenü 5, dann 1)
|
|
int wpImages = System.Text.RegularExpressions.Regex.Matches(html, @"<img\s[^>]*?\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<GhostAdminTag>();
|
|
var allTagSources = new List<WpCategory>();
|
|
|
|
// 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);
|
|
}
|
|
|
|
if (allTagSources.Count > 0)
|
|
{
|
|
Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Kategorien...");
|
|
tags = await GhostApi.EnsureTagsAsync(allTagSources);
|
|
}
|
|
|
|
// 4b. Schlagwörter (WP-Tags + SEO-Keywords) ohne Nachfrage als <meta name="keywords">
|
|
var keywords = postFile.Tags.Select(t => t.Name ?? "")
|
|
.Concat((postFile.Keywords ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
|
|
.ToList();
|
|
|
|
// 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})");
|
|
string? head = GhostApi.BuildKeywordsHead(existing.CodeinjectionHead, keywords);
|
|
if (head != null) Console.WriteLine(" [Meta] Schlagwörter als <meta name=\"keywords\"> gesetzt");
|
|
Console.Write(" Aktualisiere...");
|
|
result = await GhostApi.UpdatePostExtendedAsync(
|
|
existing.Id!, postFile.Title ?? "", html,
|
|
existing.UpdatedAt ?? "", postFile.Status ?? "published",
|
|
featureImageUrl, tags, head);
|
|
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 <meta name=\"keywords\"> gesetzt");
|
|
Console.Write(" Erstelle neuen Post...");
|
|
result = await GhostApi.CreatePostExtendedAsync(
|
|
postFile.Title ?? "", html,
|
|
postFile.Status ?? "published", postFile.Slug,
|
|
featureImageUrl, tags, head);
|
|
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))}...");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|