Wp2ghost/CSharpGhostImporter/Converter.cs
2026-10-14 19:12:29 +02:00

1439 lines
62 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")]
/// <summary>
/// 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.
/// </summary>
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 = "";
/// <summary>Zugangsdaten einer Ghost-Instanz.</summary>
internal class GhostTarget
{
public string Name = "";
public string Url = "";
public string AdminApiKey = "";
public string ContentApiKey = "";
public bool IsConfigured => Url.Length > 0 && AdminApiKey.Length > 0;
}
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>Anzeigename einer Instanz: der vergebene Name, sonst die Adresse, sonst "Ghost 1/2".</summary>
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}";
}
/// <summary>
/// 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).
/// </summary>
internal static string InstanceFile(string fileName) =>
Path.Combine(BaseDir, ActiveGhost == 0 ? fileName
: $"{Path.GetFileNameWithoutExtension(fileName)}_{ActiveGhost + 1}{Path.GetExtension(fileName)}");
/// <summary>Lädt die Einstellungen aus dem Datenordner und richtet alle Bausteine darauf ein.</summary>
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();
}
/// <summary>Speichert geänderte Einstellungen und meldet Ghost mit den neuen Zugangsdaten an.</summary>
internal static void ApplySettings()
{
SaveConfig();
ActivateGhost();
}
/// <summary>Macht eine der beiden Ghost-Instanzen zur aktiven und merkt sich die Wahl.</summary>
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<string, string>(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<string>
{
"# 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<string> ValidateAndFixLinksAsync(string? html)
{
if (string.IsNullOrWhiteSpace(html)) return html ?? "";
var doc = new HtmlDocument();
doc.LoadHtml(html);
// Alle <a>-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, <a>-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: <a>-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
// =========================================================================
/// <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}");
}
}
/// <summary>
/// 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.
/// </summary>
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<WpCategory> { 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}");
}
}
/// <summary>
/// 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.
/// </summary>
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<GhostPostFile>(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);
}
/// <summary>
/// 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.
/// </summary>
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.");
}
/// <summary>
/// Sendet einen einzelnen Post an Ghost. Liefert false, sobald etwas schiefgeht —
/// ein Post mit fehlenden Bildern oder Videos wird dann gar nicht erst angelegt.
/// </summary>
// Zuletzt eingegebene Untertags — bei einem Von–Bis-Export gehören oft mehrere Posts zum selben Thema
private static string _lastSubTags = "";
private static async Task<bool> ImportPostFromJsonAsync(string jsonPath)
{
GhostPostFile? postFile;
try
{
postFile = JsonSerializer.Deserialize<GhostPostFile>(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<string, string>();
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, @"<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);
}
// 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 <meta name="keywords">
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 <meta name=\"keywords\"> 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 <meta name=\"keywords\"> 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;
}
}