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}");
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +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}");
|
|
|
|
|
|
}
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
|
|
|
|
|
|
|
int catIdx;
|
|
|
|
|
|
while (true)
|
2026-09-24 19:23:54 +02:00
|
|
|
|
{
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +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
|
|
|
|
}
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +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
|
|
|
|
}
|
|
|
|
|
|
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +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))
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
|
.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})");
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
|
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");
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|