Wp2ghost/CSharpGhostImporter/Converter.cs

1440 lines
62 KiB
C#
Raw Normal View History

2026-09-20 08:31:23 +02:00
using System.Text.Json;
using System.Text.Json.Serialization;
2026-09-22 08:08:51 +02:00
using System.Net;
using System.Net.Http;
using HtmlAgilityPack;
2026-09-20 08:31:23 +02:00
using wp2ghost;
2026-10-14 19:12:29 +02:00
// 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
2026-09-20 08:31:23 +02:00
{
2026-10-14 19:12:29 +02:00
internal static string WpBaseUrl = "https://deine-domain.com";
2026-09-20 08:31:23 +02:00
internal static string OutputZipPath = "ghost_import.zip";
2026-10-14 19:12:29 +02:00
internal static int MaxPosts = -1;
2026-09-20 08:31:23 +02:00
private static string ConfigPath = "";
2026-10-14 19:12:29 +02:00
internal static string BaseDir = "";
2026-09-20 08:31:23 +02:00
2026-10-14 19:12:29 +02:00
/// <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)
2026-09-20 08:31:23 +02:00
{
2026-10-14 19:12:29 +02:00
BaseDir = baseDir;
2026-09-20 08:31:23 +02:00
ConfigPath = Path.Combine(BaseDir, "config.txt");
LoadConfig();
ImageManager.Init(BaseDir);
2026-10-14 19:12:29 +02:00
MediaManager.Init(BaseDir);
InternalLinkManager.Init(BaseDir);
2026-09-20 08:31:23 +02:00
PostFetcher.Init(BaseDir);
GhostExporter.Init(BaseDir);
2026-09-22 08:08:51 +02:00
LinkDecisionManager.Init(BaseDir);
2026-10-14 19:12:29 +02:00
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()
{
2026-09-20 08:31:23 +02:00
GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath);
2026-10-14 19:12:29 +02:00
SlugAssignments.Init(InstanceFile("slug_zuordnung.txt"));
}
static async Task Main(string[] args)
{
Initialize(Directory.GetCurrentDirectory());
2026-09-20 08:31:23 +02:00
Console.OutputEncoding = System.Text.Encoding.UTF8;
2026-10-14 19:12:29 +02:00
// Damit Emojis/Sonderzeichen beim manuellen Editieren eingegeben werden können
if (!Console.IsInputRedirected)
{
try { Console.InputEncoding = System.Text.Encoding.Unicode; } catch { }
}
2026-09-20 08:31:23 +02:00
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;
2026-09-22 08:08:51 +02:00
case "8":
LinkDecisionManager.ShowInfo();
break;
2026-10-14 19:12:29 +02:00
case "9":
ChooseGhost();
break;
2026-09-20 08:31:23 +02:00
case "0":
ImageManager.SaveCache();
2026-09-22 08:08:51 +02:00
LinkDecisionManager.Save();
2026-09-20 08:31:23 +02:00
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}");
2026-10-14 19:12:29 +02:00
Console.WriteLine($"Ghost-Instanz : {ActiveGhost + 1} – {GhostLabel(ActiveGhost)}");
2026-09-20 08:31:23 +02:00
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");
2026-09-22 08:08:51 +02:00
Console.WriteLine(" 8 - Gespeicherte Link-Entscheidungen anzeigen");
2026-10-14 19:12:29 +02:00
Console.WriteLine(" 9 - Ghost-Instanz wechseln");
2026-09-20 08:31:23 +02:00
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
{
2026-10-14 19:12:29 +02:00
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in File.ReadAllLines(ConfigPath))
2026-09-20 08:31:23 +02:00
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
continue;
var parts = line.Split('=', 2);
if (parts.Length == 2)
2026-10-14 19:12:29 +02:00
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", "");
2026-09-20 08:31:23 +02:00
}
2026-10-14 19:12:29 +02:00
if (values.TryGetValue("activeGhost", out string? active) && int.TryParse(active, out int number)
&& number >= 1 && number <= GhostTargets.Length)
ActiveGhost = number - 1;
2026-09-20 08:31:23 +02:00
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Laden der Konfiguration: {ex.Message}");
}
}
2026-10-14 19:12:29 +02:00
// 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}";
2026-09-20 08:31:23 +02:00
static void SaveConfig()
{
var lines = new List<string>
{
"# WP2Ghost Konfiguration",
"# Ändere die Werte und starte das Programm neu",
"",
$"wpBaseUrl={WpBaseUrl}",
$"outputZipPath={OutputZipPath}",
$"maxPosts={MaxPosts}",
"",
2026-10-14 19:12:29 +02:00
"# Ghost API (optional - für direkten Push). Zwei Instanzen, activeGhost wählt die aktive (1 oder 2).",
$"activeGhost={ActiveGhost + 1}",
2026-09-20 08:31:23 +02:00
};
2026-10-14 19:12:29 +02:00
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}");
}
2026-09-20 08:31:23 +02:00
File.WriteAllLines(ConfigPath, lines);
}
2026-10-14 19:12:29 +02:00
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.");
}
2026-09-20 08:31:23 +02:00
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();
2026-10-14 19:12:29 +02:00
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;
2026-09-20 08:31:23 +02:00
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;
2026-10-14 19:12:29 +02:00
ApplySettings();
2026-09-20 08:31:23 +02:00
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("=========================================");
2026-10-14 19:12:29 +02:00
Console.WriteLine($" Instanz : {ActiveGhost + 1} – {GhostLabel(ActiveGhost)}");
2026-09-20 08:31:23 +02:00
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");
2026-10-14 19:12:29 +02:00
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)");
2026-09-20 08:31:23 +02:00
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":
2026-10-14 19:12:29 +02:00
await GhostApi.PushAllPostsAsync(html => InternalLinkManager.Rewrite(HtmlGhostConverter.Convert(html), WpBaseUrl, GhostUrl).Html);
2026-09-20 08:31:23 +02:00
break;
case "7":
await GhostUploadImagesAsync();
break;
case "8":
await GhostCreateOrUpdateFromJsonAsync();
break;
2026-10-14 19:12:29 +02:00
case "9":
await GhostAddTagToTaggedPostsAsync();
break;
case "10":
await PostChecker.RunAsync(BaseDir);
break;
2026-09-20 08:31:23 +02:00
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();
2026-09-22 08:08:51 +02:00
// Links validieren und ggf. korrigieren
html = await ValidateAndFixLinksAsync(html);
2026-09-20 08:31:23 +02:00
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}");
}
2026-09-22 08:08:51 +02:00
// =========================================================================
// 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;
2026-10-14 19:12:29 +02:00
bool repaired = false;
var candidates = new List<(string text, string href, HtmlNode node)>();
2026-09-22 08:08:51 +02:00
foreach (var anchor in anchors)
{
string href = anchor.GetAttributeValue("href", "").Trim();
if (string.IsNullOrEmpty(href)) continue;
2026-10-14 19:12:29 +02:00
// Eindeutig reparierbare Adressen (z. B. von \"...\" umschlossen) stillschweigend korrigieren
string? fixedHref = LinkChecker.TryRepair(HtmlEntity.DeEntitize(href));
if (fixedHref != null)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($" [Links] Adresse repariert: {fixedHref}");
anchor.SetAttributeValue("href", fixedHref);
href = fixedHref;
repaired = true;
2026-09-22 08:08:51 +02:00
}
2026-10-14 19:12:29 +02:00
// 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));
2026-09-22 08:08:51 +02:00
}
2026-10-14 19:12:29 +02:00
// 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;
2026-09-22 08:08:51 +02:00
// 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++)
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($" [{i + 1}] {invalidLinks[i].reason} | Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}");
2026-09-22 08:08:51 +02:00
}
Console.WriteLine("\nOptionen pro Link:");
Console.WriteLine(" d = Link löschen (Text bleibt, <a>-Tag entfernt)");
Console.WriteLine(" e = Link bearbeiten (neue URL eingeben)");
2026-10-14 19:12:29 +02:00
Console.WriteLine(" k = Link so übernehmen (beibehalten)");
Console.WriteLine(" Enter = frühere Entscheidung für diesen Link (nur wenn es eine gibt)");
2026-09-22 08:08:51 +02:00
// Für jeden invaliden Link die User-Entscheidung einholen
2026-10-14 19:12:29 +02:00
foreach (var (linkText, linkHref, anchorNode, linkReason) in invalidLinks)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
// 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);
2026-09-22 08:08:51 +02:00
// 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})");
}
2026-10-14 19:12:29 +02:00
// 2. Grund nennen und Browser-Öffnen anbieten
2026-09-22 08:08:51 +02:00
string searchQuery = Uri.EscapeDataString(linkText);
string searchUrl = $"https://www.google.com/search?q={searchQuery}";
2026-10-14 19:12:29 +02:00
Console.WriteLine($" Problem : {linkReason}");
2026-09-22 08:08:51 +02:00
2026-10-14 19:12:29 +02:00
// 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)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
string defaultLabel = previous.Decision switch
{
"delete" => "wie zuvor: löschen",
"edit" => $"wie zuvor: ersetzen durch '{previous.NewUrl}'",
_ => "wie zuvor: beibehalten"
};
options = $"Enter={defaultLabel} / {options}";
2026-09-22 08:08:51 +02:00
}
2026-10-14 19:12:29 +02:00
string decision = "";
string? newUrl = null;
while (decision == "")
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
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;
}
2026-09-22 08:08:51 +02:00
2026-10-14 19:12:29 +02:00
switch (input.Trim().ToLowerInvariant())
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
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;
2026-09-22 08:08:51 +02:00
}
}
2026-10-14 19:12:29 +02:00
switch (decision)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
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!);
2026-09-22 08:08:51 +02:00
Console.WriteLine($" -> Auf '{newUrl}' aktualisiert");
LinkDecisionManager.SaveDecision(linkText, linkHref, "edit", newUrl);
2026-10-14 19:12:29 +02:00
break;
case "keep":
2026-09-22 08:08:51 +02:00
Console.WriteLine($" -> Beibehalten");
LinkDecisionManager.SaveDecision(linkText, linkHref, "keep");
2026-10-14 19:12:29 +02:00
break;
default:
Console.WriteLine($" -> Keine Eingabe möglich, Link bleibt unverändert (wird beim nächsten Lauf erneut gefragt)");
break;
2026-09-22 08:08:51 +02:00
}
}
2026-10-14 19:12:29 +02:00
LinkQuestion.Current = null;
2026-09-22 08:08:51 +02:00
return doc.DocumentNode.OuterHtml;
}
2026-10-14 19:12:29 +02:00
// =========================================================================
// 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
};
2026-09-22 08:08:51 +02:00
// =========================================================================
// 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}");
}
}
2026-10-14 19:12:29 +02:00
/// <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()
2026-09-22 08:08:51 +02:00
{
try
{
2026-10-14 19:12:29 +02:00
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;
2026-09-22 08:08:51 +02:00
2026-10-14 19:12:29 +02:00
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)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
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)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($" ABBRUCH wegen eines Fehlers. {done} von {missing.Count} Post(s) geändert.");
return;
2026-09-22 08:08:51 +02:00
}
2026-10-14 19:12:29 +02:00
done++;
2026-09-22 08:08:51 +02:00
}
2026-10-14 19:12:29 +02:00
Console.WriteLine($" '{newTag.Name}' zu {done} Post(s) hinzugefügt.");
2026-09-22 08:08:51 +02:00
}
2026-10-14 19:12:29 +02:00
catch (Exception ex)
2026-09-22 08:08:51 +02:00
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($" [Fehler] {ex.Message}");
2026-09-22 08:08:51 +02:00
}
}
2026-10-14 19:12:29 +02:00
/// <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>
2026-09-20 08:31:23 +02:00
static async Task GhostCreateOrUpdateFromJsonAsync()
{
string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts");
if (!Directory.Exists(ghostPostsDir))
{
Console.WriteLine("Verzeichnis 'ghostPosts' nicht gefunden.");
return;
}
2026-10-14 19:12:29 +02:00
// 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)
2026-09-20 08:31:23 +02:00
{
Console.WriteLine("Keine JSON-Dateien in ghostPosts/ vorhanden.");
return;
}
2026-10-14 19:12:29 +02:00
Console.WriteLine($" {available.Count} verfügbare Posts:");
foreach (var f in available)
2026-09-20 08:31:23 +02:00
{
2026-10-14 19:12:29 +02:00
Console.Write($" {f.Id} - ");
2026-09-20 08:31:23 +02:00
try
{
2026-10-14 19:12:29 +02:00
var preview = JsonSerializer.Deserialize<GhostPostFile>(File.ReadAllText(f.Path));
2026-09-20 08:31:23 +02:00
Console.WriteLine(preview?.Title ?? "(kein Titel)");
}
catch { Console.WriteLine("(Fehler beim Lesen)"); }
}
2026-10-14 19:12:29 +02:00
Console.Write("Von ID (leer = Abbruch): ");
if (!int.TryParse(Console.ReadLine()?.Trim(), out int fromId)) return;
2026-09-20 08:31:23 +02:00
2026-10-14 19:12:29 +02:00
Console.Write($"Bis ID (leer = nur {fromId}): ");
string? toInput = Console.ReadLine()?.Trim();
int toId = fromId;
if (!string.IsNullOrEmpty(toInput) && !int.TryParse(toInput, out toId))
2026-09-20 08:31:23 +02:00
{
2026-10-14 19:12:29 +02:00
Console.WriteLine("Ungültige Bis-ID.");
2026-09-20 08:31:23 +02:00
return;
}
2026-10-14 19:12:29 +02:00
if (toId < fromId) (fromId, toId) = (toId, fromId);
2026-09-20 08:31:23 +02:00
2026-10-14 19:12:29 +02:00
var selected = available.Where(f => f.Id >= fromId && f.Id <= toId).ToList();
if (selected.Count == 0)
2026-09-20 08:31:23 +02:00
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($"Keine Posts im Bereich {fromId}–{toId} gefunden.");
2026-09-20 08:31:23 +02:00
return;
}
2026-10-14 19:12:29 +02:00
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();
2026-09-20 08:31:23 +02:00
Console.WriteLine($" Post: {postFile.Title}");
Console.WriteLine($" Slug: {postFile.Slug}");
2026-10-14 19:12:29 +02:00
// 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}");
2026-09-20 08:31:23 +02:00
Console.WriteLine($" Kategorien: {postFile.Categories.Count}");
Console.WriteLine($" Schlagwörter: {postFile.Tags.Count}");
2026-10-14 19:12:29 +02:00
Console.WriteLine($" Kommentare: {CommentBlockBuilder.CountRealComments(postFile.Comments)} (ohne Pingbacks/Reaktionen)");
2026-09-20 08:31:23 +02:00
Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}");
// 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen
var ghostUrlMap = new Dictionary<string, string>();
2026-10-14 19:12:29 +02:00
int errors = 0;
2026-09-20 08:31:23 +02:00
string html = postFile.Html ?? "";
2026-10-14 19:12:29 +02:00
// 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}");
2026-09-22 08:08:51 +02:00
html = await ValidateAndFixLinksAsync(html);
2026-09-20 08:31:23 +02:00
2026-10-14 19:12:29 +02:00
// Problemfälle im Text melden: mehrere "?" aus fehlerhafter Zeichen-Konvertierung
postFile.Title = ReviewQuestionMarksInText(postFile.Title ?? "", "Titel");
html = ReviewQuestionMarksInHtml(html);
2026-09-20 08:31:23 +02:00
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))
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($" [Fehler] {relPath} (nicht lokal)");
errors++;
2026-09-20 08:31:23 +02:00
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
2026-10-14 19:12:29 +02:00
{
2026-09-20 08:31:23 +02:00
Console.WriteLine(" FEHLER");
2026-10-14 19:12:29 +02:00
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++;
2026-09-20 08:31:23 +02:00
}
}
// 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;
2026-10-14 19:12:29 +02:00
else
errors++;
}
else
{
Console.WriteLine($" [Fehler] Featured Image nicht lokal: {postFile.FeatureImage}");
errors++;
2026-09-20 08:31:23 +02:00
}
}
// 3. __GHOST_URL__ im HTML durch echte URLs ersetzen
foreach (var kvp in ghostUrlMap)
html = html.Replace(kvp.Key, kvp.Value);
2026-10-14 19:12:29 +02:00
// Übrig gebliebene Platzhalter wären in Ghost kaputte Bilder/Videos
2026-09-20 08:31:23 +02:00
if (html.Contains("__GHOST_URL__"))
{
int remaining = System.Text.RegularExpressions.Regex.Matches(html, "__GHOST_URL__").Count;
2026-10-14 19:12:29 +02:00
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;
2026-09-20 08:31:23 +02:00
}
// 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.
2026-09-20 08:31:23 +02:00
var tags = new List<GhostAdminTag>();
2026-09-22 08:08:51 +02:00
var allTagSources = new List<WpCategory>();
2026-09-24 19:23:54 +02:00
// 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)
2026-09-24 19:23:54 +02:00
{
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.");
2026-09-24 19:23:54 +02:00
}
// 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))}");
2026-09-24 19:23:54 +02:00
}
else if (postFile.Categories.Count == 1)
{
2026-09-22 08:08:51 +02:00
allTagSources.AddRange(postFile.Categories);
2026-09-24 19:23:54 +02:00
}
2026-10-14 19:12:29 +02:00
// 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 });
}
}
2026-09-22 08:08:51 +02:00
if (allTagSources.Count > 0)
2026-09-20 08:31:23 +02:00
{
2026-10-14 19:12:29 +02:00
Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Tag(s)...");
2026-09-22 08:08:51 +02:00
tags = await GhostApi.EnsureTagsAsync(allTagSources);
2026-10-14 19:12:29 +02:00
// 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;
}
2026-09-20 08:31:23 +02:00
}
// 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))
2026-10-14 19:12:29 +02:00
.Where(k => !CategoryRules.IsIgnored(k))
.ToList();
2026-09-20 08:31:23 +02:00
// 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert
2026-10-14 19:12:29 +02:00
// 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 ?? "";
2026-09-20 08:31:23 +02:00
Console.Write(" Prüfe Ghost...");
2026-10-14 19:12:29 +02:00
var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{lookupSlug}", 1);
2026-09-20 08:31:23 +02:00
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");
2026-09-20 08:31:23 +02:00
Console.Write(" Aktualisiere...");
result = await GhostApi.UpdatePostExtendedAsync(
existing.Id!, postFile.Title ?? "", html,
existing.UpdatedAt ?? "", postFile.Status ?? "published",
2026-10-14 19:12:29 +02:00
featureImageUrl, tags, head, publishedAt);
2026-09-20 08:31:23 +02:00
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");
2026-09-20 08:31:23 +02:00
Console.Write(" Erstelle neuen Post...");
result = await GhostApi.CreatePostExtendedAsync(
postFile.Title ?? "", html,
postFile.Status ?? "published", postFile.Slug,
2026-10-14 19:12:29 +02:00
featureImageUrl, tags, head, publishedAt);
2026-09-20 08:31:23 +02:00
Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER");
}
2026-10-14 19:12:29 +02:00
return result != null;
2026-09-20 08:31:23 +02:00
}
}