first commit
This commit is contained in:
commit
48d2de05ed
19 changed files with 3448 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/CSharpGhostImporter/.vs/*
|
||||||
|
/CSharpGhostImporter/bin/*
|
||||||
|
/CSharpGhostImporter/obj/*
|
||||||
3
AGENTS.md
Normal file
3
AGENTS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Du arbeitest an einem Ghost-Projekt. Nutze bei Fragen zur API oder Dokumentation
|
||||||
|
immer das integrierte `webfetch`-Tool, um die aktuelle Referenz abzurufen:
|
||||||
|
- Hauptdokumentation: https://docs.ghost.org/llms-full.txt
|
||||||
14
CSharpGhostImporter/CSharpGhostImporter.csproj
Normal file
14
CSharpGhostImporter/CSharpGhostImporter.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>CSharpGhostImporter</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HtmlAgilityPack" Version="1.13.0" />
|
||||||
|
<PackageReference Include="jose-jwt" Version="5.3.0" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
635
CSharpGhostImporter/Converter.cs
Normal file
635
CSharpGhostImporter/Converter.cs
Normal file
|
|
@ -0,0 +1,635 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using wp2ghost;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
private static string WpBaseUrl = "https://deine-domain.com";
|
||||||
|
internal static string OutputZipPath = "ghost_import.zip";
|
||||||
|
private static int MaxPosts = -1;
|
||||||
|
private static string ConfigPath = "";
|
||||||
|
private static string BaseDir = "";
|
||||||
|
private static string GhostUrl = "";
|
||||||
|
private static string GhostAdminApiKey = "";
|
||||||
|
private static string GhostContentApiKey = "";
|
||||||
|
|
||||||
|
static async Task Main(string[] args)
|
||||||
|
{
|
||||||
|
BaseDir = Directory.GetCurrentDirectory();
|
||||||
|
ConfigPath = Path.Combine(BaseDir, "config.txt");
|
||||||
|
|
||||||
|
LoadConfig();
|
||||||
|
ImageManager.Init(BaseDir);
|
||||||
|
PostFetcher.Init(BaseDir);
|
||||||
|
GhostExporter.Init(BaseDir);
|
||||||
|
GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath);
|
||||||
|
|
||||||
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
Console.Title = "WP2Ghost Converter";
|
||||||
|
|
||||||
|
|
||||||
|
bool running = true;
|
||||||
|
while (running)
|
||||||
|
{
|
||||||
|
ShowMenu();
|
||||||
|
string? choice = Console.ReadLine()?.Trim();
|
||||||
|
|
||||||
|
switch (choice)
|
||||||
|
{
|
||||||
|
case "1":
|
||||||
|
await PostFetcher.FetchFromWordPressAsync(WpBaseUrl, MaxPosts);
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
await GhostExporter.CreateZipAsync();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
ConfigureSettings();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
ShowCacheInfo();
|
||||||
|
break;
|
||||||
|
case "5":
|
||||||
|
PostFetcher.ClearProcessedPosts();
|
||||||
|
break;
|
||||||
|
case "6":
|
||||||
|
ImageManager.ClearAll();
|
||||||
|
break;
|
||||||
|
case "7":
|
||||||
|
await GhostMenuAsync();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
ImageManager.SaveCache();
|
||||||
|
running = false;
|
||||||
|
Console.WriteLine("Programm beendet. Auf Wiedersehen!");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Ungültige Auswahl. Bitte erneut eingeben.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ShowMenu()
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine(" WP2Ghost - WordPress to Ghost");
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine($"WordPress-URL : {WpBaseUrl}");
|
||||||
|
Console.WriteLine($"Ausgabe-Datei : {OutputZipPath}");
|
||||||
|
Console.WriteLine($"Max. Beiträge/Seiten: {(MaxPosts == -1 ? "Alle" : MaxPosts.ToString())}");
|
||||||
|
Console.WriteLine($"Abgefragte Objekte : {PostFetcher.ProcessedCount}");
|
||||||
|
Console.WriteLine($"Ghost-URL : {(string.IsNullOrEmpty(GhostUrl) ? "(nicht konfiguriert)" : GhostUrl)}");
|
||||||
|
Console.WriteLine($"Ghost-Status : {(GhostApi.IsConfigured() ? "verbunden" : "nicht konfiguriert")}");
|
||||||
|
Console.WriteLine("-----------------------------------------");
|
||||||
|
Console.WriteLine(" 1 - Von WordPress abrufen (Posts & Pages)");
|
||||||
|
Console.WriteLine(" 2 - Ghost ZIP erstellen");
|
||||||
|
Console.WriteLine(" 3 - Einstellungen konfigurieren");
|
||||||
|
Console.WriteLine(" 4 - Cache-Informationen anzeigen");
|
||||||
|
Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen");
|
||||||
|
Console.WriteLine(" 6 - Bilder lokal löschen");
|
||||||
|
Console.WriteLine(" 7 - Beiträge direkt an Ghost senden");
|
||||||
|
Console.WriteLine(" 0 - Beenden");
|
||||||
|
Console.WriteLine("-----------------------------------------");
|
||||||
|
Console.Write("Auswahl: ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LoadConfig()
|
||||||
|
{
|
||||||
|
if (!File.Exists(ConfigPath))
|
||||||
|
{
|
||||||
|
SaveConfig();
|
||||||
|
Console.WriteLine($"Neue Konfigurationsdatei erstellt: {ConfigPath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(ConfigPath);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var parts = line.Split('=', 2);
|
||||||
|
if (parts.Length == 2)
|
||||||
|
{
|
||||||
|
string key = parts[0].Trim();
|
||||||
|
string value = parts[1].Trim();
|
||||||
|
|
||||||
|
switch (key.ToLower())
|
||||||
|
{
|
||||||
|
case "wpbaseurl":
|
||||||
|
WpBaseUrl = value;
|
||||||
|
break;
|
||||||
|
case "outputzippath":
|
||||||
|
OutputZipPath = value;
|
||||||
|
break;
|
||||||
|
case "maxposts":
|
||||||
|
if (int.TryParse(value, out int max))
|
||||||
|
MaxPosts = max;
|
||||||
|
break;
|
||||||
|
case "ghosturl":
|
||||||
|
GhostUrl = value;
|
||||||
|
break;
|
||||||
|
case "ghostadminapikey":
|
||||||
|
GhostAdminApiKey = value;
|
||||||
|
break;
|
||||||
|
case "ghostcontentapikey":
|
||||||
|
GhostContentApiKey = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fehler beim Laden der Konfiguration: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SaveConfig()
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"# WP2Ghost Konfiguration",
|
||||||
|
"# Ändere die Werte und starte das Programm neu",
|
||||||
|
"",
|
||||||
|
$"wpBaseUrl={WpBaseUrl}",
|
||||||
|
$"outputZipPath={OutputZipPath}",
|
||||||
|
$"maxPosts={MaxPosts}",
|
||||||
|
"",
|
||||||
|
"# Ghost API (optional - für direkten Push)",
|
||||||
|
$"ghostUrl={GhostUrl}",
|
||||||
|
$"ghostAdminApiKey={GhostAdminApiKey}",
|
||||||
|
};
|
||||||
|
File.WriteAllLines(ConfigPath, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ConfigureSettings()
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("--- Einstellungen konfigurieren ---");
|
||||||
|
Console.Write($"WordPress-URL [{WpBaseUrl}]: ");
|
||||||
|
string? input = Console.ReadLine()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(input))
|
||||||
|
WpBaseUrl = input;
|
||||||
|
|
||||||
|
Console.Write($"Ausgabe-Datei [{OutputZipPath}]: ");
|
||||||
|
input = Console.ReadLine()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(input))
|
||||||
|
OutputZipPath = input;
|
||||||
|
|
||||||
|
Console.Write($"Maximale Anzahl Posts (leer = alle) [{(MaxPosts == -1 ? "alle" : MaxPosts.ToString())}]: ");
|
||||||
|
input = Console.ReadLine()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(input) && int.TryParse(input, out int max))
|
||||||
|
MaxPosts = max;
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("--- Ghost API (optional) ---");
|
||||||
|
Console.Write($"Ghost-URL [{GhostUrl}]: ");
|
||||||
|
input = Console.ReadLine()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(input))
|
||||||
|
GhostUrl = input;
|
||||||
|
|
||||||
|
Console.Write($"Ghost Admin API Key [{(string.IsNullOrEmpty(GhostAdminApiKey) ? "(nicht gesetzt)" : "****")}] : ");
|
||||||
|
input = Console.ReadLine()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(input))
|
||||||
|
GhostAdminApiKey = input;
|
||||||
|
|
||||||
|
Console.Write($"Ghost Content API Key [{(string.IsNullOrEmpty(GhostContentApiKey) ? "(nicht gesetzt)" : "****")}] : ");
|
||||||
|
input = Console.ReadLine()?.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(input))
|
||||||
|
GhostContentApiKey = input;
|
||||||
|
|
||||||
|
GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath);
|
||||||
|
|
||||||
|
SaveConfig();
|
||||||
|
Console.WriteLine("Einstellungen gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ShowCacheInfo()
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("--- Cache-Informationen ---");
|
||||||
|
Console.WriteLine($"Abgefragte Objekte: {PostFetcher.ProcessedCount}");
|
||||||
|
Console.WriteLine($"Ghost-Beiträge geladen: {GhostExporter.PostCount}");
|
||||||
|
|
||||||
|
ImageManager.ShowInfo();
|
||||||
|
PostFetcher.ShowInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task GhostMenuAsync()
|
||||||
|
{
|
||||||
|
if (!GhostApi.IsConfigured())
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Ghost API nicht konfiguriert. Bitte zuerst Option 3 (Einstellungen) nutzen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool inGhostMenu = true;
|
||||||
|
while (inGhostMenu)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine(" Ghost Admin API");
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine($" Ghost-URL : {GhostUrl}");
|
||||||
|
Console.WriteLine("-----------------------------------------");
|
||||||
|
Console.WriteLine(" 1 - Posts anzeigen (Ghost)");
|
||||||
|
Console.WriteLine(" 2 - Post lesen (nach ID)");
|
||||||
|
Console.WriteLine(" 3 - Post erstellen");
|
||||||
|
Console.WriteLine(" 4 - Post bearbeiten");
|
||||||
|
Console.WriteLine(" 5 - Post löschen");
|
||||||
|
Console.WriteLine(" 6 - Alle lokalen Posts an Ghost senden");
|
||||||
|
Console.WriteLine(" 7 - Bilder hochladen");
|
||||||
|
Console.WriteLine(" 8 - Post aus JSON erstellen/aktualisieren");
|
||||||
|
Console.WriteLine(" 0 - Zurück zum Hauptmenü");
|
||||||
|
Console.WriteLine("-----------------------------------------");
|
||||||
|
Console.Write("Auswahl: ");
|
||||||
|
|
||||||
|
string? choice = Console.ReadLine()?.Trim();
|
||||||
|
switch (choice)
|
||||||
|
{
|
||||||
|
case "1":
|
||||||
|
await GhostBrowsePostsAsync();
|
||||||
|
break;
|
||||||
|
case "2":
|
||||||
|
await GhostReadPostAsync();
|
||||||
|
break;
|
||||||
|
case "3":
|
||||||
|
await GhostCreatePostAsync();
|
||||||
|
break;
|
||||||
|
case "4":
|
||||||
|
await GhostEditPostAsync();
|
||||||
|
break;
|
||||||
|
case "5":
|
||||||
|
await GhostDeletePostAsync();
|
||||||
|
break;
|
||||||
|
case "6":
|
||||||
|
await GhostApi.PushAllPostsAsync(html => HtmlGhostConverter.Convert(html));
|
||||||
|
break;
|
||||||
|
case "7":
|
||||||
|
await GhostUploadImagesAsync();
|
||||||
|
break;
|
||||||
|
case "8":
|
||||||
|
await GhostCreateOrUpdateFromJsonAsync();
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
inGhostMenu = false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("Ungültige Auswahl.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task GhostBrowsePostsAsync()
|
||||||
|
{
|
||||||
|
Console.Write("Filter (leer = alle, z.B. status:published): ");
|
||||||
|
string? filter = Console.ReadLine()?.Trim();
|
||||||
|
Console.Write("Anzahl (leer = 50): ");
|
||||||
|
string? limitStr = Console.ReadLine()?.Trim();
|
||||||
|
int limit = int.TryParse(limitStr, out int l) ? l : 50;
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Lade Posts von Ghost...");
|
||||||
|
var posts = await GhostApi.BrowsePostsAsync(filter ?? "", limit);
|
||||||
|
|
||||||
|
if (posts.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Keine Posts gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($" {"ID",-24} {"Status",-12} {"Titel"}");
|
||||||
|
Console.WriteLine($" {new string('-', 23)} {new string('-', 11)} {new string('-', 30)}");
|
||||||
|
foreach (var post in posts)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" {post.Id,-24} {post.Status,-12} {post.Title}");
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($" Gesamt: {posts.Count} Posts");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task GhostReadPostAsync()
|
||||||
|
{
|
||||||
|
Console.Write("Post-ID: ");
|
||||||
|
string? id = Console.ReadLine()?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(id)) return;
|
||||||
|
|
||||||
|
var post = await GhostApi.GetPostAsync(id);
|
||||||
|
if (post == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Post nicht gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($" ID : {post.Id}");
|
||||||
|
Console.WriteLine($" Titel : {post.Title}");
|
||||||
|
Console.WriteLine($" Slug : {post.Slug}");
|
||||||
|
Console.WriteLine($" Status : {post.Status}");
|
||||||
|
Console.WriteLine($" Erstellt : {post.CreatedAt}");
|
||||||
|
Console.WriteLine($" Aktualisiert: {post.UpdatedAt}");
|
||||||
|
Console.WriteLine($" Feature Img : {post.FeatureImage ?? "(keine)"}");
|
||||||
|
Console.WriteLine($" HTML ({(post.Html?.Length ?? 0)} Zeichen):");
|
||||||
|
Console.WriteLine($" {new string('-', 50)}");
|
||||||
|
string preview = post.Html ?? "";
|
||||||
|
if (preview.Length > 500) preview = preview[..500] + "...";
|
||||||
|
Console.WriteLine($" {preview}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task GhostCreatePostAsync()
|
||||||
|
{
|
||||||
|
Console.Write("Titel: ");
|
||||||
|
string? title = Console.ReadLine()?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(title)) return;
|
||||||
|
|
||||||
|
Console.Write("HTML-Inhalt: ");
|
||||||
|
string? html = Console.ReadLine()?.Trim();
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task GhostCreateOrUpdateFromJsonAsync()
|
||||||
|
{
|
||||||
|
string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts");
|
||||||
|
if (!Directory.Exists(ghostPostsDir))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Verzeichnis 'ghostPosts' nicht gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonFiles = Directory.GetFiles(ghostPostsDir, "*.json");
|
||||||
|
if (jsonFiles.Length == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Keine JSON-Dateien in ghostPosts/ vorhanden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($" {jsonFiles.Length} verfügbare Posts:");
|
||||||
|
foreach (var f in jsonFiles)
|
||||||
|
{
|
||||||
|
string id = Path.GetFileNameWithoutExtension(f);
|
||||||
|
Console.Write($" {id} - ");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var preview = JsonSerializer.Deserialize<GhostPostFile>(File.ReadAllText(f));
|
||||||
|
Console.WriteLine(preview?.Title ?? "(kein Titel)");
|
||||||
|
}
|
||||||
|
catch { Console.WriteLine("(Fehler beim Lesen)"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write("Ghost-ID des Posts: ");
|
||||||
|
string? ghostId = Console.ReadLine()?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(ghostId)) return;
|
||||||
|
|
||||||
|
string jsonPath = Path.Combine(ghostPostsDir, $"{ghostId}.json");
|
||||||
|
if (!File.Exists(jsonPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Datei {jsonPath} nicht gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var postFile = JsonSerializer.Deserialize<GhostPostFile>(File.ReadAllText(jsonPath));
|
||||||
|
if (postFile == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("JSON konnte nicht gelesen werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($" Post: {postFile.Title}");
|
||||||
|
Console.WriteLine($" Slug: {postFile.Slug}");
|
||||||
|
Console.WriteLine($" Kategorien: {postFile.Categories.Count}");
|
||||||
|
Console.WriteLine($" Kommentare: {postFile.Comments.Count}");
|
||||||
|
Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}");
|
||||||
|
|
||||||
|
// 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen
|
||||||
|
var ghostUrlMap = new Dictionary<string, string>();
|
||||||
|
string html = postFile.Html ?? "";
|
||||||
|
|
||||||
|
if (postFile.ImageFiles.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Bilder] Lade {postFile.ImageFiles.Count} Bilder hoch...");
|
||||||
|
foreach (string relPath in postFile.ImageFiles)
|
||||||
|
{
|
||||||
|
string localPath = Path.Combine(BaseDir, "images", relPath);
|
||||||
|
if (!File.Exists(localPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Übersprungen] {relPath} (nicht lokal)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Console.Write($" [Upload] {Path.GetFileName(localPath)}...");
|
||||||
|
string? url = await GhostApi.UploadImageIfNewAsync(localPath);
|
||||||
|
if (url != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" -> {url}");
|
||||||
|
// Backslash-normalisiert für alte JSONs
|
||||||
|
string normalized = relPath.Replace("\\", "/");
|
||||||
|
string placeholder1 = $"__GHOST_URL__/content/images/{normalized}";
|
||||||
|
string placeholder2 = $"__GHOST_URL__/content/images/{relPath}";
|
||||||
|
ghostUrlMap[placeholder1] = url;
|
||||||
|
if (placeholder1 != placeholder2)
|
||||||
|
ghostUrlMap[placeholder2] = url;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Console.WriteLine(" FEHLER");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Featured Image hochladen (separat, da nicht in ImageFiles)
|
||||||
|
string? featureImageUrl = null;
|
||||||
|
if (!string.IsNullOrEmpty(postFile.FeatureImage))
|
||||||
|
{
|
||||||
|
string localFeatPath = postFile.FeatureImage.Replace("__GHOST_URL__/content/images/", "");
|
||||||
|
localFeatPath = Path.Combine(BaseDir, "images", localFeatPath);
|
||||||
|
if (File.Exists(localFeatPath))
|
||||||
|
{
|
||||||
|
Console.Write(" [Featured Image]...");
|
||||||
|
featureImageUrl = await GhostApi.UploadImageIfNewAsync(localFeatPath);
|
||||||
|
Console.WriteLine(featureImageUrl != null ? $" OK" : " FEHLER");
|
||||||
|
if (featureImageUrl != null)
|
||||||
|
ghostUrlMap[postFile.FeatureImage] = featureImageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. __GHOST_URL__ im HTML durch echte URLs ersetzen
|
||||||
|
foreach (var kvp in ghostUrlMap)
|
||||||
|
html = html.Replace(kvp.Key, kvp.Value);
|
||||||
|
|
||||||
|
// Falls noch Rest Platzhalter → Warnung
|
||||||
|
if (html.Contains("__GHOST_URL__"))
|
||||||
|
{
|
||||||
|
int remaining = System.Text.RegularExpressions.Regex.Matches(html, "__GHOST_URL__").Count;
|
||||||
|
Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Tags erstellen/zuordnen
|
||||||
|
var tags = new List<GhostAdminTag>();
|
||||||
|
if (postFile.Categories.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine(" [Tags] Verarbeite Kategorien...");
|
||||||
|
tags = await GhostApi.EnsureTagsAsync(postFile.Categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert
|
||||||
|
Console.Write(" Prüfe Ghost...");
|
||||||
|
var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{postFile.Slug}", 1);
|
||||||
|
var existing = existingPosts.FirstOrDefault();
|
||||||
|
|
||||||
|
GhostAdminPost? result = null;
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" vorhanden (ID: {existing.Id})");
|
||||||
|
Console.Write(" Aktualisiere...");
|
||||||
|
result = await GhostApi.UpdatePostExtendedAsync(
|
||||||
|
existing.Id!, postFile.Title ?? "", html,
|
||||||
|
existing.UpdatedAt ?? "", postFile.Status ?? "published",
|
||||||
|
featureImageUrl, tags, postFile.Keywords);
|
||||||
|
Console.WriteLine(result != null ? " OK" : " FEHLER");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(" nicht vorhanden");
|
||||||
|
Console.Write(" Erstelle neuen Post...");
|
||||||
|
result = await GhostApi.CreatePostExtendedAsync(
|
||||||
|
postFile.Title ?? "", html,
|
||||||
|
postFile.Status ?? "published", postFile.Slug,
|
||||||
|
featureImageUrl, tags, postFile.Keywords);
|
||||||
|
Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Kommentare als Members verarbeiten
|
||||||
|
if (postFile.Comments.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.Write($" {postFile.Comments.Count} Kommentare verarbeiten? (j/n): ");
|
||||||
|
string? commentConfirm = Console.ReadLine()?.Trim();
|
||||||
|
if (commentConfirm?.ToLower() == "j")
|
||||||
|
{
|
||||||
|
foreach (var comment in postFile.Comments)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(comment.AuthorEmail)) continue;
|
||||||
|
var member = await GhostApi.EnsureMemberAsync(comment.AuthorName ?? "Anonymous", comment.AuthorEmail);
|
||||||
|
if (member != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Kommentar] {comment.AuthorName}: {comment.Content?.Substring(0, Math.Min(50, comment.Content?.Length ?? 0))}...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
662
CSharpGhostImporter/GhostApi.cs
Normal file
662
CSharpGhostImporter/GhostApi.cs
Normal file
|
|
@ -0,0 +1,662 @@
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
// =============================================================================
|
||||||
|
// GhostApi — Ghost Admin API: JWT-Auth, Bilder hochladen, Posts erstellen/bearbeiten
|
||||||
|
// =============================================================================
|
||||||
|
public static class GhostApi
|
||||||
|
{
|
||||||
|
private static readonly HttpClient httpClient = new();
|
||||||
|
private static string GhostUrl = "";
|
||||||
|
private static string AdminApiKey = "";
|
||||||
|
private static string ContentApiKey = "";
|
||||||
|
private static int ImageContainerPathParts = 0;
|
||||||
|
|
||||||
|
public static void Init(string ghostUrl, string adminApiKey, string contentApiKey, string imageContainerPath)
|
||||||
|
{
|
||||||
|
GhostUrl = ghostUrl.TrimEnd('/');
|
||||||
|
AdminApiKey = adminApiKey;
|
||||||
|
ContentApiKey = contentApiKey;
|
||||||
|
ImageContainerPathParts = Path.GetFullPath(imageContainerPath).Split(Path.DirectorySeparatorChar).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsConfigured()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(GhostUrl) && !string.IsNullOrEmpty(AdminApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateToken()
|
||||||
|
{
|
||||||
|
var parts = AdminApiKey.Split(':', 2);
|
||||||
|
if (parts.Length != 2)
|
||||||
|
throw new ArgumentException("Admin API Key muss im Format {id}:{secret} sein.");
|
||||||
|
|
||||||
|
string keyId = parts[0];
|
||||||
|
string secret = parts[1];
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var header = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "alg", "HS256" },
|
||||||
|
{ "typ", "JWT" },
|
||||||
|
{ "kid", keyId }
|
||||||
|
};
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "iat", now.ToUnixTimeSeconds() },
|
||||||
|
{ "exp", now.ToUnixTimeSeconds() + 300 },
|
||||||
|
{ "aud", "/admin/" }
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] secretBytes = Convert.FromHexString(secret);
|
||||||
|
return Jose.JWT.Encode(payload, secretBytes, Jose.JwsAlgorithm.HS256, extraHeaders: header);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpRequestMessage> CreateRequestAsync(HttpMethod method, string endpoint)
|
||||||
|
{
|
||||||
|
string token = GenerateToken();
|
||||||
|
var request = new HttpRequestMessage(method, $"{GhostUrl}/ghost/api/admin/{endpoint}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Ghost", token);
|
||||||
|
request.Headers.Add("Accept-Version", "v5.0");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<List<GhostAdminPost>> BrowsePostsAsync(string filter = "", int limit = 50)
|
||||||
|
{
|
||||||
|
string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image";
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminPostsResponse>(json);
|
||||||
|
return result?.Posts ?? new List<GhostAdminPost>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminPost?> GetPostAsync(string postId)
|
||||||
|
{
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Get, $"posts/{postId}/?fields=id,title,slug,status,created_at,updated_at,html,feature_image,featured");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminPostsResponse>(json);
|
||||||
|
return result?.Posts?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminPost?> CreatePostAsync(string title, string html, string status = "published", string? slug = null)
|
||||||
|
{
|
||||||
|
var post = new GhostAdminPostEdit
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Html = html,
|
||||||
|
Status = status
|
||||||
|
};
|
||||||
|
if (slug != null) post.Slug = slug;
|
||||||
|
|
||||||
|
var body = new { posts = new[] { post } };
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Post, "posts/?source=html");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminPostsResponse>(json);
|
||||||
|
return result?.Posts?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminPost?> UpdatePostAsync(string postId, string title, string html, string updatedAt, string status = "published")
|
||||||
|
{
|
||||||
|
var post = new GhostAdminPostEdit
|
||||||
|
{
|
||||||
|
Id = postId,
|
||||||
|
Title = title,
|
||||||
|
Html = html,
|
||||||
|
Status = status,
|
||||||
|
UpdatedAt = updatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
var body = new { posts = new[] { post } };
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/?source=html");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminPostsResponse>(json);
|
||||||
|
return result?.Posts?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> DeletePostAsync(string postId)
|
||||||
|
{
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Delete, $"posts/{postId}/");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Tags =====================
|
||||||
|
public static async Task<List<GhostAdminTag>> BrowseTagsAsync(string filter = "", int limit = 100)
|
||||||
|
{
|
||||||
|
string endpoint = $"tags/?limit={limit}&fields=id,name,slug";
|
||||||
|
if (!string.IsNullOrEmpty(filter))
|
||||||
|
endpoint += $"&filter={Uri.EscapeDataString(filter)}";
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode) return new();
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json);
|
||||||
|
return result?.Tags ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminTag?> CreateTagAsync(string name, string? slug = null)
|
||||||
|
{
|
||||||
|
var tag = new GhostAdminTagEdit { Name = name };
|
||||||
|
if (slug != null) tag.Slug = slug;
|
||||||
|
var body = new { tags = new[] { tag } };
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Post, "tags/");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] Tag erstellen: {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json);
|
||||||
|
return result?.Tags?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<List<GhostAdminTag>> EnsureTagsAsync(List<WpCategory> categories)
|
||||||
|
{
|
||||||
|
var result = new List<GhostAdminTag>();
|
||||||
|
var existingTags = await BrowseTagsAsync();
|
||||||
|
var existingByName = existingTags.Where(t => t.Name != null).ToDictionary(t => t.Name!.ToLowerInvariant(), t => t);
|
||||||
|
|
||||||
|
foreach (var cat in categories)
|
||||||
|
{
|
||||||
|
string name = cat.Name ?? "";
|
||||||
|
if (string.IsNullOrEmpty(name)) continue;
|
||||||
|
if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing))
|
||||||
|
{
|
||||||
|
result.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Write($" [Tag] Erstelle '{name}'...");
|
||||||
|
var newTag = await CreateTagAsync(name, cat.Slug);
|
||||||
|
if (newTag != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(" OK");
|
||||||
|
result.Add(newTag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Console.WriteLine(" FEHLER");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Members =====================
|
||||||
|
public static async Task<List<GhostAdminMember>> BrowseMembersAsync(string filter = "", int limit = 100)
|
||||||
|
{
|
||||||
|
string endpoint = $"members/?limit={limit}&fields=id,name,email,status";
|
||||||
|
if (!string.IsNullOrEmpty(filter))
|
||||||
|
endpoint += $"&filter={Uri.EscapeDataString(filter)}";
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode) return new();
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminMembersResponse>(json);
|
||||||
|
return result?.Members ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminMember?> CreateMemberAsync(string name, string email)
|
||||||
|
{
|
||||||
|
var member = new GhostAdminMemberEdit
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Email = email,
|
||||||
|
Status = "free"
|
||||||
|
};
|
||||||
|
var body = new { members = new[] { member } };
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Post, "members/");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] Member erstellen: {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminMembersResponse>(json);
|
||||||
|
return result?.Members?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminMember?> EnsureMemberAsync(string name, string email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(email)) return null;
|
||||||
|
var existing = await BrowseMembersAsync($"email:'{email}'");
|
||||||
|
var found = existing.FirstOrDefault(m => m.Email?.ToLowerInvariant() == email.ToLowerInvariant());
|
||||||
|
if (found != null) return found;
|
||||||
|
|
||||||
|
Console.Write($" [Member] Erstelle '{name}' ({email})...");
|
||||||
|
var newMember = await CreateMemberAsync(name, email);
|
||||||
|
Console.WriteLine(newMember != null ? " OK" : " FEHLER");
|
||||||
|
return newMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Posts (erweitert) =====================
|
||||||
|
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? metaKeywords = null)
|
||||||
|
{
|
||||||
|
var post = new GhostAdminPostEdit
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Html = html,
|
||||||
|
Status = status,
|
||||||
|
FeatureImage = featureImage,
|
||||||
|
MetaKeywords = metaKeywords
|
||||||
|
};
|
||||||
|
if (slug != null) post.Slug = slug;
|
||||||
|
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
|
||||||
|
|
||||||
|
var body = new { posts = new[] { post } };
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Post, "posts/?source=html");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminPostsResponse>(json);
|
||||||
|
return result?.Posts?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? metaKeywords = null)
|
||||||
|
{
|
||||||
|
var post = new GhostAdminPostEdit
|
||||||
|
{
|
||||||
|
Id = postId,
|
||||||
|
Title = title,
|
||||||
|
Html = html,
|
||||||
|
Status = status,
|
||||||
|
UpdatedAt = updatedAt,
|
||||||
|
FeatureImage = featureImage,
|
||||||
|
MetaKeywords = metaKeywords
|
||||||
|
};
|
||||||
|
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
|
||||||
|
|
||||||
|
var body = new { posts = new[] { post } };
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/?source=html");
|
||||||
|
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostAdminPostsResponse>(json);
|
||||||
|
return result?.Posts?.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> UploadImageAsync(string localPath, DateTime? postDate = null)
|
||||||
|
{
|
||||||
|
if (!File.Exists(localPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = await CreateRequestAsync(HttpMethod.Post, "images/upload/");
|
||||||
|
|
||||||
|
string fileName = Path.GetFileName(localPath);
|
||||||
|
var fileBytes = await File.ReadAllBytesAsync(localPath);
|
||||||
|
var fileContent = new ByteArrayContent(fileBytes);
|
||||||
|
var pathParts = Path.GetFullPath(localPath).Split("\\");
|
||||||
|
|
||||||
|
string ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
string mimeType = ext switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".svg" => "image/svg+xml",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
|
||||||
|
|
||||||
|
var formContent = new MultipartFormDataContent();
|
||||||
|
formContent.Add(fileContent, "file", fileName);
|
||||||
|
if (pathParts.Length > ImageContainerPathParts + 1)
|
||||||
|
{
|
||||||
|
string refPath = string.Join("/", pathParts.Skip(ImageContainerPathParts).Take(pathParts.Length - ImageContainerPathParts - 1));
|
||||||
|
if (!string.IsNullOrEmpty(refPath))
|
||||||
|
formContent.Add(new StringContent(refPath), "ref");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Content = formContent;
|
||||||
|
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string error = await response.Content.ReadAsStringAsync();
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] Upload fehlgeschlagen: {response.StatusCode}: {error}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<GhostImageUploadResponse>(json);
|
||||||
|
return result?.Images?.FirstOrDefault()?.Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> ImageExistsAsync(string ghostUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ghostUrl) || !ghostUrl.StartsWith("http"))
|
||||||
|
return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Head, ghostUrl);
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> UploadImageIfNewAsync(string localPath, DateTime? postDate = null)
|
||||||
|
{
|
||||||
|
if (!File.Exists(localPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileName = Path.GetFileName(localPath);
|
||||||
|
var pathParts = Path.GetFullPath(localPath).Split(Path.DirectorySeparatorChar);
|
||||||
|
string refPath = "";
|
||||||
|
if (pathParts.Length > ImageContainerPathParts + 1)
|
||||||
|
refPath = string.Join("/", pathParts.Skip(ImageContainerPathParts).Take(pathParts.Length - ImageContainerPathParts - 1));
|
||||||
|
|
||||||
|
string expectedUrl = $"{GhostUrl}/content/images/{refPath.Replace("\\", "/")}/{fileName}";
|
||||||
|
if (await ImageExistsAsync(expectedUrl))
|
||||||
|
{
|
||||||
|
Console.Write($" [Upload] {fileName} existiert bereits, überspringen");
|
||||||
|
return expectedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await UploadImageAsync(localPath, postDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<int> PushAllPostsAsync(Func<string, string> htmlTransformer)
|
||||||
|
{
|
||||||
|
if (!IsConfigured())
|
||||||
|
{
|
||||||
|
Console.WriteLine("Ghost API nicht konfiguriert. Bitte ghostUrl und ghostAdminApiKey in config.txt setzen.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Sende Beiträge an Ghost...");
|
||||||
|
|
||||||
|
var localPosts = PostFetcher.Posts;
|
||||||
|
if (localPosts.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Keine Beiträge geladen. Bitte zuerst Option 1 ausführen.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ghostPosts = await BrowsePostsAsync();
|
||||||
|
var ghostBySlug = ghostPosts.Where(p => p.Slug != null).ToDictionary(p => p.Slug!, p => p);
|
||||||
|
var ghostByTitle = ghostPosts.Where(p => p.Title != null).ToDictionary(p => p.Title!, p => p);
|
||||||
|
|
||||||
|
int created = 0, updated = 0, skipped = 0;
|
||||||
|
|
||||||
|
foreach (var localPost in localPosts)
|
||||||
|
{
|
||||||
|
string title = localPost.Title ?? "";
|
||||||
|
string slug = localPost.Slug ?? "";
|
||||||
|
string html = htmlTransformer(localPost.Html ?? "");
|
||||||
|
|
||||||
|
GhostAdminPost? existing = null;
|
||||||
|
if (!string.IsNullOrEmpty(slug) && ghostBySlug.ContainsKey(slug))
|
||||||
|
existing = ghostBySlug[slug];
|
||||||
|
else if (!string.IsNullOrEmpty(title) && ghostByTitle.ContainsKey(title))
|
||||||
|
existing = ghostByTitle[title];
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
if (existing.UpdatedAt == localPost.CreatedAt)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Write($" [Update] {title}...");
|
||||||
|
var updatedPost = await UpdatePostAsync(existing.Id!, title, html, existing.UpdatedAt ?? "");
|
||||||
|
if (updatedPost != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(" OK");
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Console.WriteLine(" FEHLER");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Write($" [Neu] {title}...");
|
||||||
|
var newPost = await CreatePostAsync(title, html, "published", slug);
|
||||||
|
if (newPost != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(" OK");
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Console.WriteLine(" FEHLER");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine($" Ghost: {created} erstellt, {updated} aktualisiert, {skipped} übersprungen");
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
|
||||||
|
return created + updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Ghost Admin API DTOs
|
||||||
|
// =============================================================================
|
||||||
|
public class GhostAdminPostsResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("posts")]
|
||||||
|
public List<GhostAdminPost>? Posts { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminPost
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("html")]
|
||||||
|
public string? Html { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public string? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("updated_at")]
|
||||||
|
public string? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("feature_image")]
|
||||||
|
public string? FeatureImage { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("featured")]
|
||||||
|
public bool Featured { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminPostEdit
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("html")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Html { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("updated_at")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("feature_image")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? FeatureImage { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public List<GhostAdminTagRef>? Tags { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("meta_title")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? MetaTitle { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("meta_description")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? MetaDescription { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("meta_keywords")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? MetaKeywords { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostImageUploadResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("images")]
|
||||||
|
public List<GhostImageUploadResult>? Images { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostImageUploadResult
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")]
|
||||||
|
public string? Url { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ref")]
|
||||||
|
public string? Ref { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Tag DTOs =====================
|
||||||
|
public class GhostAdminTagsResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<GhostAdminTag>? Tags { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminTag
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminTagEdit
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminTagRef
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Member DTOs =====================
|
||||||
|
public class GhostAdminMembersResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("members")]
|
||||||
|
public List<GhostAdminMember>? Members { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminMember
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostAdminMemberEdit
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
CSharpGhostImporter/GhostDto.cs
Normal file
39
CSharpGhostImporter/GhostDto.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
public class GhostRoot { [JsonPropertyName("db")] public List<GhostDb> Db { get; set; } = new() { new GhostDb() }; }
|
||||||
|
public class GhostDb { [JsonPropertyName("data")] public GhostData Data { get; set; } = new(); }
|
||||||
|
public class GhostData { [JsonPropertyName("posts")] public List<GhostPost> Posts { get; set; } = new(); }
|
||||||
|
|
||||||
|
public class GhostPost
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("title")] public string? Title { get; set; }
|
||||||
|
[JsonPropertyName("slug")] public string? Slug { get; set; }
|
||||||
|
[JsonPropertyName("html")] public string? Html { get; set; }
|
||||||
|
[JsonPropertyName("markdown")] public string? Markdown { get; set; }
|
||||||
|
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||||
|
[JsonPropertyName("created_at")] public string? CreatedAt { get; set; }
|
||||||
|
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GhostPostFile
|
||||||
|
{
|
||||||
|
[JsonPropertyName("wp_id")] public int WpId { get; set; }
|
||||||
|
[JsonPropertyName("wp_slug")] public string? WpSlug { get; set; }
|
||||||
|
[JsonPropertyName("ghost_id")] public string? GhostId { get; set; }
|
||||||
|
[JsonPropertyName("title")] public string? Title { get; set; }
|
||||||
|
[JsonPropertyName("slug")] public string? Slug { get; set; }
|
||||||
|
[JsonPropertyName("excerpt")] public string? Excerpt { get; set; }
|
||||||
|
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||||
|
[JsonPropertyName("created_at")] public string? CreatedAt { get; set; }
|
||||||
|
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; }
|
||||||
|
[JsonPropertyName("feature_image")] public string? FeatureImage { get; set; }
|
||||||
|
[JsonPropertyName("categories")] public List<WpCategory> Categories { get; set; } = new();
|
||||||
|
[JsonPropertyName("keywords")] public string? Keywords { get; set; }
|
||||||
|
[JsonPropertyName("comments")] public List<WpComment> Comments { get; set; } = new();
|
||||||
|
[JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new();
|
||||||
|
[JsonPropertyName("html")] public string? Html { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
107
CSharpGhostImporter/GhostExporter.cs
Normal file
107
CSharpGhostImporter/GhostExporter.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
// =============================================================================
|
||||||
|
// GhostExporter — Ghost ZIP erstellen
|
||||||
|
// =============================================================================
|
||||||
|
static class GhostExporter
|
||||||
|
{
|
||||||
|
private static string BaseDir = "";
|
||||||
|
private static string MarkdownDir = "";
|
||||||
|
|
||||||
|
public static int PostCount => PostFetcher.PostCount;
|
||||||
|
|
||||||
|
public static void Init(string baseDir)
|
||||||
|
{
|
||||||
|
BaseDir = baseDir;
|
||||||
|
MarkdownDir = Path.Combine(baseDir, "markdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task CreateZipAsync()
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Erstelle Ghost ZIP-Datei...");
|
||||||
|
|
||||||
|
var posts = PostFetcher.Posts;
|
||||||
|
if (posts.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Keine Beiträge geladen. Bitte zuerst Option 1 ausführen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string outputPath = Path.Combine(BaseDir, Program.OutputZipPath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//PostFetcher.SaveAllMarkdownFiles();
|
||||||
|
|
||||||
|
string jsonString = JsonSerializer.Serialize(new GhostRoot
|
||||||
|
{
|
||||||
|
Db = new List<GhostDb> { new GhostDb { Data = new GhostData { Posts = posts.ToList() } } }
|
||||||
|
}, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
});
|
||||||
|
|
||||||
|
string outputDir = Path.GetDirectoryName(outputPath) ?? BaseDir;
|
||||||
|
if (!Directory.Exists(outputDir))
|
||||||
|
Directory.CreateDirectory(outputDir);
|
||||||
|
|
||||||
|
using (var zipStream = new FileStream(outputPath, FileMode.Create))
|
||||||
|
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
|
||||||
|
{
|
||||||
|
var jsonEntry = archive.CreateEntry("import.json");
|
||||||
|
using (var writer = new StreamWriter(jsonEntry.Open()))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(MarkdownDir))
|
||||||
|
{
|
||||||
|
foreach (string mdFile in Directory.GetFiles(MarkdownDir, "*.md", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
string relativeMdPath = Path.GetFileName(mdFile);
|
||||||
|
var mdEntry = archive.CreateEntry($"content/{relativeMdPath}");
|
||||||
|
using (var entryStream = mdEntry.Open())
|
||||||
|
using (var fileStream = File.OpenRead(mdFile))
|
||||||
|
{
|
||||||
|
await fileStream.CopyToAsync(entryStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in ImageManager.CachedFiles)
|
||||||
|
{
|
||||||
|
string fullLocalPath = Path.Combine(Path.Combine(BaseDir, "images"), file.RelativePath);
|
||||||
|
if (File.Exists(fullLocalPath))
|
||||||
|
{
|
||||||
|
var fileEntry = archive.CreateEntry($"content/images/{file.RelativePath}");
|
||||||
|
using (var entryStream = fileEntry.Open())
|
||||||
|
using (var fileStream = File.OpenRead(fullLocalPath))
|
||||||
|
{
|
||||||
|
await fileStream.CopyToAsync(entryStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine($" Ghost ZIP erstellt!");
|
||||||
|
Console.WriteLine($" Beiträge: {posts.Count}");
|
||||||
|
Console.WriteLine($" Bilder: {ImageManager.CachedFiles.Count} Dateien");
|
||||||
|
Console.WriteLine($" Ausgabe: {outputPath}");
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\nFehler beim Erstellen der ZIP-Datei: {ex.Message}");
|
||||||
|
Console.WriteLine($"Stack Trace: {ex.StackTrace}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
570
CSharpGhostImporter/HtmlConverter.cs
Normal file
570
CSharpGhostImporter/HtmlConverter.cs
Normal file
|
|
@ -0,0 +1,570 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
// =============================================================================
|
||||||
|
// HtmlGhostConverter — WordPress HTML zu Ghost-kompatibles HTML konvertieren
|
||||||
|
// =============================================================================
|
||||||
|
public static class HtmlGhostConverter
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string?> WpClassMap = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Block-Elemente → Ghost-Äquivalent oder null (Klasse entfernen)
|
||||||
|
{ "wp-block-paragraph", null },
|
||||||
|
{ "wp-block-heading", null },
|
||||||
|
{ "wp-block-list", null },
|
||||||
|
{ "wp-block-quote", null },
|
||||||
|
{ "wp-block-code", null },
|
||||||
|
{ "wp-block-separator", null },
|
||||||
|
{ "wp-block-group", null },
|
||||||
|
{ "wp-block-columns", null },
|
||||||
|
{ "wp-block-column", null },
|
||||||
|
{ "wp-block-freeform", null },
|
||||||
|
{ "wp-block-verse", null },
|
||||||
|
{ "wp-block-preformatted", null },
|
||||||
|
|
||||||
|
// Ghost-Kompatible Klassen
|
||||||
|
{ "wp-block-image", "kg-card kg-image-card" },
|
||||||
|
{ "wp-block-gallery", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-pullquote", "kg-card kg-pullquote" },
|
||||||
|
{ "wp-block-embed", "kg-card kg-embed-card" },
|
||||||
|
{ "wp-block-embed-wrapper", "kg-card kg-embed-card" },
|
||||||
|
{ "wp-block-video", "kg-card kg-embed-card" },
|
||||||
|
{ "wp-block-audio", "kg-card kg-embed-card" },
|
||||||
|
{ "wp-block-cover", "kg-card kg-header-card" },
|
||||||
|
{ "wp-block-media-text", "kg-card kg-width-wide" },
|
||||||
|
{ "wp-block-table", "kg-card kg-table-card" },
|
||||||
|
{ "wp-block-button", "kg-card kg-button-card" },
|
||||||
|
{ "wp-block-buttons", "kg-card kg-button-card" },
|
||||||
|
{ "wp-block-file", "kg-card kg-file-card" },
|
||||||
|
|
||||||
|
// Sub-Elemente
|
||||||
|
{ "wp-block-button__link", "gh-btn" },
|
||||||
|
{ "wp-block-button__link__inline", "gh-btn" },
|
||||||
|
{ "wp-element-button", "gh-btn" },
|
||||||
|
{ "has-text-align-center", "kg-width-wide" },
|
||||||
|
{ "has-text-align-left", "" },
|
||||||
|
{ "has-text-align-right", "" },
|
||||||
|
{ "is-style-wide", "kg-width-wide" },
|
||||||
|
{ "is-style-full", "kg-width-wide" },
|
||||||
|
{ "is-style-large", "kg-width-wide" },
|
||||||
|
|
||||||
|
// Theme-spezifisch
|
||||||
|
{ "wp-block-post-title", null },
|
||||||
|
{ "wp-block-post-content", null },
|
||||||
|
{ "wp-block-site-title", null },
|
||||||
|
{ "wp-block-site-tagline", null },
|
||||||
|
{ "wp-block-query", null },
|
||||||
|
{ "wp-block-comments", null },
|
||||||
|
{ "wp-block-comment-template", null },
|
||||||
|
{ "wp-block-template-part", null },
|
||||||
|
|
||||||
|
// CoBlocks
|
||||||
|
{ "wp-block-coblocks-gallery-stacked", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-coblocks-gallery-collage", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-coblocks-gallery-masonry", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-coblocks-gallery-thumbnails", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-coblocks-alert", "kg-card kg-alert-card" },
|
||||||
|
{ "wp-block-coblocks-accordion", "kg-card kg-accordion-card" },
|
||||||
|
{ "wp-block-coblocks-accordion-item", "kg-accordion-item" },
|
||||||
|
{ "wp-block-coblocks-row", "kg-width-wide" },
|
||||||
|
{ "wp-block-coblocks-column", "" },
|
||||||
|
{ "wp-block-coblocks-click-to-tweet", "kg-card kg-click-to-tweet" },
|
||||||
|
{ "wp-block-coblocks-social-profiles", "kg-card kg-social-link" },
|
||||||
|
|
||||||
|
// Jetpack
|
||||||
|
{ "wp-block-jetpack-tiled-gallery", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-jetpack-slideshow", "kg-card kg-gallery-card" },
|
||||||
|
{ "wp-block-jetpack-story", "kg-card kg-gallery-card" },
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Convert(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(html)) return string.Empty;
|
||||||
|
|
||||||
|
var htmlDoc = new HtmlDocument();
|
||||||
|
htmlDoc.LoadHtml(html);
|
||||||
|
|
||||||
|
int totalGalleries = 0;
|
||||||
|
totalGalleries += ConvertClassicGalleries(htmlDoc);
|
||||||
|
totalGalleries += ConvertGutenbergGalleries(htmlDoc);
|
||||||
|
totalGalleries += ConvertJetpackGalleries(htmlDoc);
|
||||||
|
totalGalleries += ConvertCoBlocksGalleries(htmlDoc);
|
||||||
|
|
||||||
|
if (totalGalleries > 0)
|
||||||
|
Console.WriteLine($" [Gallery] {totalGalleries} Galerie(n) konvertiert");
|
||||||
|
|
||||||
|
ConvertSingleImages(htmlDoc);
|
||||||
|
ConvertFigures(htmlDoc);
|
||||||
|
ConvertWpClasses(htmlDoc);
|
||||||
|
|
||||||
|
return htmlDoc.DocumentNode.OuterHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Gallery-Typ 1: WordPress Classic Gallery
|
||||||
|
// HTML: <div class='gallery gallery-columns-3'>
|
||||||
|
// <figure class='gallery-item'>
|
||||||
|
// <div class='gallery-icon portrait'>
|
||||||
|
// <a href='...'><img src='thumb' data-orig-file='full.jpg' /></a>
|
||||||
|
// </div>
|
||||||
|
// </figure>
|
||||||
|
// </div>
|
||||||
|
// =========================================================================
|
||||||
|
private static int ConvertClassicGalleries(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
|
||||||
|
"//div[contains(@class, 'gallery') and not(contains(@class, 'wp-block'))]"
|
||||||
|
);
|
||||||
|
if (galleryNodes == null) return 0;
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var gallery in galleryNodes.ToList())
|
||||||
|
{
|
||||||
|
var items = gallery.SelectNodes(".//figure[contains(@class, 'gallery-item')]");
|
||||||
|
if (items == null || items.Count == 0) continue;
|
||||||
|
|
||||||
|
var ghostGallery = htmlDoc.CreateElement("figure");
|
||||||
|
SetClasses(ghostGallery, "kg-card kg-gallery-card");
|
||||||
|
AddWideClass(ghostGallery, gallery);
|
||||||
|
|
||||||
|
var container = htmlDoc.CreateElement("div");
|
||||||
|
container.AddClass("kg-gallery-container");
|
||||||
|
|
||||||
|
var wrappers = new List<HtmlNode>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var img = item.SelectSingleNode(".//img");
|
||||||
|
if (img == null) continue;
|
||||||
|
|
||||||
|
string src = GetBestImageUrl(img);
|
||||||
|
if (string.IsNullOrEmpty(src)) continue;
|
||||||
|
|
||||||
|
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrappers.Count == 0) continue;
|
||||||
|
|
||||||
|
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||||
|
ghostGallery.AppendChild(container);
|
||||||
|
|
||||||
|
var captionNode = gallery.SelectSingleNode(".//*[contains(@class, 'gallery-caption')] | .//figcaption");
|
||||||
|
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText))
|
||||||
|
{
|
||||||
|
var newCaption = htmlDoc.CreateElement("figcaption");
|
||||||
|
newCaption.InnerHtml = captionNode.InnerHtml;
|
||||||
|
ghostGallery.AppendChild(newCaption);
|
||||||
|
ghostGallery.AddClass("kg-card-hascaption");
|
||||||
|
}
|
||||||
|
|
||||||
|
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Gallery-Typ 2: WordPress Gutenberg Gallery
|
||||||
|
// =========================================================================
|
||||||
|
private static int ConvertGutenbergGalleries(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
|
||||||
|
"//figure[contains(@class, 'wp-block-gallery')] | " +
|
||||||
|
"//div[contains(@class, 'wp-block-gallery')] | " +
|
||||||
|
"//ul[contains(@class, 'wp-block-gallery')]"
|
||||||
|
);
|
||||||
|
if (galleryNodes == null) return 0;
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var gallery in galleryNodes.ToList())
|
||||||
|
{
|
||||||
|
var imgs = gallery.SelectNodes(".//img");
|
||||||
|
if (imgs == null || imgs.Count == 0) continue;
|
||||||
|
|
||||||
|
var ghostGallery = htmlDoc.CreateElement("figure");
|
||||||
|
SetClasses(ghostGallery, "kg-card kg-gallery-card");
|
||||||
|
AddWideClass(ghostGallery, gallery);
|
||||||
|
|
||||||
|
var container = htmlDoc.CreateElement("div");
|
||||||
|
container.AddClass("kg-gallery-container");
|
||||||
|
|
||||||
|
var wrappers = new List<HtmlNode>();
|
||||||
|
foreach (var img in imgs)
|
||||||
|
{
|
||||||
|
string src = GetBestImageUrl(img);
|
||||||
|
if (string.IsNullOrEmpty(src)) continue;
|
||||||
|
|
||||||
|
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrappers.Count == 0) continue;
|
||||||
|
|
||||||
|
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||||
|
ghostGallery.AppendChild(container);
|
||||||
|
|
||||||
|
var captionNode = gallery.SelectSingleNode(".//figcaption");
|
||||||
|
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText))
|
||||||
|
{
|
||||||
|
var newCaption = htmlDoc.CreateElement("figcaption");
|
||||||
|
newCaption.InnerHtml = captionNode.InnerHtml;
|
||||||
|
ghostGallery.AppendChild(newCaption);
|
||||||
|
ghostGallery.AddClass("kg-card-hascaption");
|
||||||
|
}
|
||||||
|
|
||||||
|
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Gallery-Typ 3: Jetpack Tiled Gallery
|
||||||
|
// =========================================================================
|
||||||
|
private static int ConvertJetpackGalleries(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
|
||||||
|
"//div[contains(@class, 'wp-block-jetpack-tiled-gallery')] | " +
|
||||||
|
"//figure[contains(@class, 'wp-block-jetpack-tiled-gallery')]"
|
||||||
|
);
|
||||||
|
if (galleryNodes == null) return 0;
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var gallery in galleryNodes.ToList())
|
||||||
|
{
|
||||||
|
var imgs = gallery.SelectNodes(".//img");
|
||||||
|
if (imgs == null || imgs.Count == 0) continue;
|
||||||
|
|
||||||
|
var ghostGallery = htmlDoc.CreateElement("figure");
|
||||||
|
SetClasses(ghostGallery, "kg-card kg-gallery-card");
|
||||||
|
AddWideClass(ghostGallery, gallery);
|
||||||
|
|
||||||
|
var container = htmlDoc.CreateElement("div");
|
||||||
|
container.AddClass("kg-gallery-container");
|
||||||
|
|
||||||
|
var wrappers = new List<HtmlNode>();
|
||||||
|
foreach (var img in imgs)
|
||||||
|
{
|
||||||
|
string src = GetBestImageUrl(img);
|
||||||
|
if (string.IsNullOrEmpty(src)) continue;
|
||||||
|
|
||||||
|
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrappers.Count == 0) continue;
|
||||||
|
|
||||||
|
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||||
|
ghostGallery.AppendChild(container);
|
||||||
|
|
||||||
|
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Gallery-Typ 4: CoBlocks Gallery
|
||||||
|
// =========================================================================
|
||||||
|
private static int ConvertCoBlocksGalleries(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var galleryNodes = htmlDoc.DocumentNode.SelectNodes(
|
||||||
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-masonry')] | " +
|
||||||
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-stacked')] | " +
|
||||||
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-collage')] | " +
|
||||||
|
"//figure[contains(@class, 'wp-block-coblocks-gallery-thumbnails')]"
|
||||||
|
);
|
||||||
|
if (galleryNodes == null) return 0;
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var gallery in galleryNodes.ToList())
|
||||||
|
{
|
||||||
|
var imgs = gallery.SelectNodes(".//img");
|
||||||
|
if (imgs == null || imgs.Count == 0) continue;
|
||||||
|
|
||||||
|
var ghostGallery = htmlDoc.CreateElement("figure");
|
||||||
|
SetClasses(ghostGallery, "kg-card kg-gallery-card");
|
||||||
|
AddWideClass(ghostGallery, gallery);
|
||||||
|
|
||||||
|
var container = htmlDoc.CreateElement("div");
|
||||||
|
container.AddClass("kg-gallery-container");
|
||||||
|
|
||||||
|
var wrappers = new List<HtmlNode>();
|
||||||
|
foreach (var img in imgs)
|
||||||
|
{
|
||||||
|
string src = GetBestImageUrl(img);
|
||||||
|
if (string.IsNullOrEmpty(src)) continue;
|
||||||
|
|
||||||
|
wrappers.Add(BuildGalleryImage(htmlDoc, img, src));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrappers.Count == 0) continue;
|
||||||
|
|
||||||
|
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||||
|
ghostGallery.AppendChild(container);
|
||||||
|
|
||||||
|
gallery.ParentNode?.ReplaceChild(ghostGallery, gallery);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Galerie-Helfer: Spaltenanzahl, Wide-Klasse, Bild-Wrapper, Zeilen
|
||||||
|
// =========================================================================
|
||||||
|
private static int GetGalleryColumns(HtmlNode gallery)
|
||||||
|
{
|
||||||
|
string classes = gallery.GetAttributeValue("class", "");
|
||||||
|
var match = Regex.Match(classes, @"columns-(\d+)");
|
||||||
|
if (match.Success && int.TryParse(match.Groups[1].Value, out int cols) && cols >= 1 && cols <= 9)
|
||||||
|
return cols;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddWideClass(HtmlNode ghostGallery, HtmlNode originalGallery)
|
||||||
|
{
|
||||||
|
string origClass = originalGallery.GetAttributeValue("class", "");
|
||||||
|
if (origClass.Contains("is-style-wide") || origClass.Contains("alignwide"))
|
||||||
|
ghostGallery.AddClass("kg-width-wide");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HtmlNode BuildGalleryImage(HtmlDocument htmlDoc, HtmlNode originalImg, string src)
|
||||||
|
{
|
||||||
|
var imgWrapper = htmlDoc.CreateElement("div");
|
||||||
|
imgWrapper.AddClass("kg-gallery-image");
|
||||||
|
|
||||||
|
var newImg = htmlDoc.CreateElement("img");
|
||||||
|
newImg.SetAttributeValue("src", src);
|
||||||
|
|
||||||
|
double w = 0, h = 0;
|
||||||
|
|
||||||
|
string origSize = originalImg.GetAttributeValue("data-orig-size", "");
|
||||||
|
if (!string.IsNullOrEmpty(origSize))
|
||||||
|
{
|
||||||
|
var parts = origSize.Split(',');
|
||||||
|
if (parts.Length == 2 &&
|
||||||
|
double.TryParse(parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double ow) &&
|
||||||
|
double.TryParse(parts[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double oh))
|
||||||
|
{
|
||||||
|
w = ow;
|
||||||
|
h = oh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (w <= 0 || h <= 0)
|
||||||
|
{
|
||||||
|
if (double.TryParse(originalImg.GetAttributeValue("width", ""), NumberStyles.Float, CultureInfo.InvariantCulture, out double aw) &&
|
||||||
|
double.TryParse(originalImg.GetAttributeValue("height", ""), NumberStyles.Float, CultureInfo.InvariantCulture, out double ah))
|
||||||
|
{
|
||||||
|
w = aw;
|
||||||
|
h = ah;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (w > 0 && h > 0)
|
||||||
|
{
|
||||||
|
newImg.SetAttributeValue("width", ((int)w).ToString(CultureInfo.InvariantCulture));
|
||||||
|
newImg.SetAttributeValue("height", ((int)h).ToString(CultureInfo.InvariantCulture));
|
||||||
|
string ratio = (w / h).ToString("G6", CultureInfo.InvariantCulture);
|
||||||
|
imgWrapper.SetAttributeValue("style", $"flex: {ratio} 1 0%;");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
imgWrapper.SetAttributeValue("style", "flex: 1 1 0%;");
|
||||||
|
}
|
||||||
|
|
||||||
|
newImg.SetAttributeValue("loading", "lazy");
|
||||||
|
newImg.SetAttributeValue("alt", originalImg.GetAttributeValue("alt", ""));
|
||||||
|
|
||||||
|
imgWrapper.AppendChild(newImg);
|
||||||
|
return imgWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendGalleryRows(HtmlDocument htmlDoc, HtmlNode container, List<HtmlNode> wrappers, int columns)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < wrappers.Count; i += columns)
|
||||||
|
{
|
||||||
|
var row = htmlDoc.CreateElement("div");
|
||||||
|
row.AddClass("kg-gallery-row");
|
||||||
|
|
||||||
|
int end = Math.Min(i + columns, wrappers.Count);
|
||||||
|
for (int j = i; j < end; j++)
|
||||||
|
row.AppendChild(wrappers[j]);
|
||||||
|
|
||||||
|
container.AppendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper: Beste Bild-URL aus img-Element holen
|
||||||
|
// Priorität: data-orig-file → data-large-file → srcset (letzter/Widest) → src
|
||||||
|
// =========================================================================
|
||||||
|
private static string GetBestImageUrl(HtmlNode img)
|
||||||
|
{
|
||||||
|
// 1. data-orig-file (Vollbild)
|
||||||
|
string src = img.GetAttributeValue("data-orig-file", "");
|
||||||
|
if (!string.IsNullOrEmpty(src)) return src;
|
||||||
|
|
||||||
|
// 2. data-large-file
|
||||||
|
src = img.GetAttributeValue("data-large-file", "");
|
||||||
|
if (!string.IsNullOrEmpty(src)) return src;
|
||||||
|
|
||||||
|
// 3. srcset (größte Variante nehmen)
|
||||||
|
string srcset = img.GetAttributeValue("srcset", "");
|
||||||
|
if (!string.IsNullOrEmpty(srcset))
|
||||||
|
{
|
||||||
|
src = GetLargestFromSrcset(srcset);
|
||||||
|
if (!string.IsNullOrEmpty(src)) return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. src (Fallback)
|
||||||
|
return img.GetAttributeValue("src", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLargestFromSrcset(string srcset)
|
||||||
|
{
|
||||||
|
string bestUrl = "";
|
||||||
|
int bestWidth = 0;
|
||||||
|
|
||||||
|
foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
string trimmed = part.Trim();
|
||||||
|
string[] tokens = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (tokens.Length < 2) continue;
|
||||||
|
|
||||||
|
string url = tokens[0];
|
||||||
|
string descriptor = tokens[1];
|
||||||
|
|
||||||
|
if (int.TryParse(descriptor.Replace("w", ""), out int width) && width > bestWidth)
|
||||||
|
{
|
||||||
|
bestWidth = width;
|
||||||
|
bestUrl = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Einzelbilder (nicht in Galerien)
|
||||||
|
// =========================================================================
|
||||||
|
private static void ConvertSingleImages(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var singleImgNodes = htmlDoc.DocumentNode.SelectNodes("//img");
|
||||||
|
if (singleImgNodes == null) return;
|
||||||
|
|
||||||
|
foreach (var img in singleImgNodes.ToList())
|
||||||
|
{
|
||||||
|
if (img.Ancestors("figure").Any(a => HasClass(a, "kg-gallery-card")))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string src = GetBestImageUrl(img);
|
||||||
|
if (string.IsNullOrEmpty(src)) continue;
|
||||||
|
|
||||||
|
var parentFigure = img.Ancestors("figure").FirstOrDefault();
|
||||||
|
if (parentFigure != null)
|
||||||
|
{
|
||||||
|
SetClasses(parentFigure, "kg-card kg-image-card");
|
||||||
|
img.AddClass("kg-image");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var wrapper = htmlDoc.CreateElement("figure");
|
||||||
|
SetClasses(wrapper, "kg-card kg-image-card");
|
||||||
|
img.AddClass("kg-image");
|
||||||
|
|
||||||
|
var imgParent = img.ParentNode;
|
||||||
|
if (imgParent != null)
|
||||||
|
{
|
||||||
|
imgParent.InsertBefore(wrapper, img);
|
||||||
|
imgParent.RemoveChild(img);
|
||||||
|
wrapper.AppendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Figure ohne kg-card → kg-width-wide
|
||||||
|
// =========================================================================
|
||||||
|
private static void ConvertFigures(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var figures = htmlDoc.DocumentNode.SelectNodes("//figure");
|
||||||
|
if (figures == null) return;
|
||||||
|
|
||||||
|
foreach (var figure in figures.ToList())
|
||||||
|
{
|
||||||
|
if (HasClass(figure, "kg-card")) continue;
|
||||||
|
SetClasses(figure, "kg-width-wide kg-card-width-wide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// WP-Klassen mappen
|
||||||
|
// =========================================================================
|
||||||
|
private static void ConvertWpClasses(HtmlDocument htmlDoc)
|
||||||
|
{
|
||||||
|
var allNodes = htmlDoc.DocumentNode.SelectNodes("//*[contains(@class, 'wp-block')]");
|
||||||
|
if (allNodes == null) return;
|
||||||
|
|
||||||
|
foreach (var node in allNodes.ToList())
|
||||||
|
{
|
||||||
|
string classes = node.GetAttributeValue("class", "");
|
||||||
|
if (string.IsNullOrEmpty(classes)) continue;
|
||||||
|
|
||||||
|
var classList = classes.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
|
||||||
|
var newClasses = new List<string>();
|
||||||
|
bool changed = false;
|
||||||
|
|
||||||
|
foreach (string cls in classList)
|
||||||
|
{
|
||||||
|
if (WpClassMap.TryGetValue(cls, out string? ghostClass))
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
if (ghostClass != null && ghostClass.Length > 0)
|
||||||
|
newClasses.AddRange(ghostClass.Split(' '));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newClasses.Add(cls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
string finalClasses = string.Join(" ", newClasses.Where(c => c.Length > 0).Distinct());
|
||||||
|
if (finalClasses.Length > 0)
|
||||||
|
node.SetAttributeValue("class", finalClasses);
|
||||||
|
else
|
||||||
|
node.Attributes.Remove("class");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
var separators = htmlDoc.DocumentNode.SelectNodes("//div[contains(@class, 'wp-block-separator')]");
|
||||||
|
if (separators != null)
|
||||||
|
{
|
||||||
|
foreach (var sep in separators.ToList())
|
||||||
|
{
|
||||||
|
var hr = htmlDoc.CreateElement("hr");
|
||||||
|
foreach (var attr in sep.Attributes)
|
||||||
|
hr.SetAttributeValue(attr.Name, attr.Value);
|
||||||
|
sep.ParentNode?.ReplaceChild(hr, sep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasClass(HtmlNode node, string className)
|
||||||
|
{
|
||||||
|
string classes = node.GetAttributeValue("class", "");
|
||||||
|
return classes.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Any(c => c.Equals(className, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetClasses(HtmlNode node, string classes)
|
||||||
|
{
|
||||||
|
node.SetAttributeValue("class", classes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
CSharpGhostImporter/HtmlToMarkdownConverter.cs
Normal file
143
CSharpGhostImporter/HtmlToMarkdownConverter.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
// =============================================================================
|
||||||
|
// HTML zu Markdown Konverter
|
||||||
|
// =============================================================================
|
||||||
|
public static class HtmlToMarkdownConverter
|
||||||
|
{
|
||||||
|
public static string Convert(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
string result = html;
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, "<!--.*?-->", "", System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<h([1-6])[^>]*>(.*?)</h\1>", m => new string('#', int.Parse(m.Groups[1].Value)) + " " + StripTags(m.Groups[2].Value) + Environment.NewLine + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<blockquote[^>]*>(.*?)</blockquote>", m => "> " + StripTags(m.Groups[1].Value).Replace(Environment.NewLine, Environment.NewLine + "> ") + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<pre[^>]*><code[^>]*>(.*?)</code></pre>", m => "```\n" + StripTags(m.Groups[1].Value) + "\n```\n\n", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<code[^>]*>(.*?)</code>", m => "`" + StripTags(m.Groups[1].Value) + "`", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<ul[^>]*>(.*?)</ul>", m => ConvertList(m.Groups[1].Value, "-"), System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<ol[^>]*>(.*?)</ol>", m => ConvertOrderedList(m.Groups[1].Value), System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = ConvertTables(result);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<p[^>]*>(.*?)</p>", m => CleanInnerHtml(m.Groups[1].Value) + Environment.NewLine + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<br\s*/?>", Environment.NewLine, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<strong[^>]*>(.*?)</strong>", "**$1**", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<b[^>]*>(.*?)</b>", "**$1**", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<em[^>]*>(.*?)</em>", "*$1*", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<i[^>]*>(.*?)</i>", "*$1*", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<a\s+[^>]*href=[""']([^""']+)[""'][^>]*>(.*?)</a>", "[$2]($1)", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<img\s+[^>]*src=[""']([^""']+)[""'][^>]*alt=[""']([^""']*)[""'][^>]*>", m => "![" + m.Groups[2].Value + "](" + m.Groups[1].Value + ")", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<img\s+[^>]*src=[""']([^""']+)[""'][^>]*>", m => "", System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"<div[^>]*class=[""'][^""']*wp-block-heading[^""']*[""'][^>]*>(.*?)</div>", m => "# " + StripTags(m.Groups[1].Value) + Environment.NewLine + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result,
|
||||||
|
@"<figure[^>]*>(.*?)</figure>",
|
||||||
|
m => ConvertFigure(m.Groups[1].Value),
|
||||||
|
System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
result = StripTagsKeepFigure(result);
|
||||||
|
|
||||||
|
result = System.Text.RegularExpressions.Regex.Replace(result, @"\n{3,}", "\n\n");
|
||||||
|
|
||||||
|
result = result.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace(""", "\"").Replace("’", "'").Replace(" ", " ").Replace("—", "—").Replace("–", "–");
|
||||||
|
|
||||||
|
return result.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripTags(string html) =>
|
||||||
|
System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", "");
|
||||||
|
|
||||||
|
private static string CleanInnerHtml(string html)
|
||||||
|
{
|
||||||
|
string cleaned = StripTags(html);
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\*\*(.+?)\*\*", "**$1**");
|
||||||
|
return cleaned.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConvertFigure(string figureContent)
|
||||||
|
{
|
||||||
|
string imgSrc = "", altText = "", caption = "";
|
||||||
|
|
||||||
|
var imgMatch = System.Text.RegularExpressions.Regex.Match(figureContent, @"<img\s+[^>]*src=[""']([^""']+)[""']");
|
||||||
|
if (imgMatch.Success) imgSrc = imgMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var altMatch = System.Text.RegularExpressions.Regex.Match(figureContent, @"alt=[""']([^""']*)[""']");
|
||||||
|
if (altMatch.Success) altText = altMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var figMatch = System.Text.RegularExpressions.Regex.Match(figureContent, @"<figcaption[^>]*>(.*?)</figcaption>", System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
if (figMatch.Success) caption = System.Text.RegularExpressions.Regex.Replace(figMatch.Groups[1].Value, "<[^>]*>", "").Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(imgSrc)) return "";
|
||||||
|
|
||||||
|
string result = $"<figure class=\"kg-width-wide kg-card-width-wide\"><img src=\"{imgSrc}\" loading=\"lazy\"";
|
||||||
|
if (!string.IsNullOrEmpty(altText)) result += $" alt=\"{altText}\"";
|
||||||
|
result += ">";
|
||||||
|
if (!string.IsNullOrEmpty(caption)) result += $"<figcaption>{caption}</figcaption>";
|
||||||
|
result += "</figure>";
|
||||||
|
return result + Environment.NewLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripTagsKeepFigure(string html)
|
||||||
|
{
|
||||||
|
var figureBlocks = new List<string>();
|
||||||
|
html = System.Text.RegularExpressions.Regex.Replace(html, @"<figure[^>]*>.*?</figure>", m => { figureBlocks.Add(m.Value); return $"__FIGURE_{figureBlocks.Count - 1}__"; }, System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", "");
|
||||||
|
for (int i = 0; i < figureBlocks.Count; i++)
|
||||||
|
html = html.Replace($"__FIGURE_{i}__", figureBlocks[i]);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConvertList(string listHtml, string bullet)
|
||||||
|
{
|
||||||
|
listHtml = System.Text.RegularExpressions.Regex.Replace(listHtml, @"<li[^>]*>(.*?)</li>", m => bullet + " " + CleanInnerHtml(m.Groups[1].Value) + Environment.NewLine, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
return StripTags(listHtml).Trim() + Environment.NewLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConvertOrderedList(string listHtml)
|
||||||
|
{
|
||||||
|
int counter = 0;
|
||||||
|
listHtml = System.Text.RegularExpressions.Regex.Replace(listHtml, @"<li[^>]*>(.*?)</li>", m => { counter++; return counter + ". " + CleanInnerHtml(m.Groups[1].Value) + Environment.NewLine; }, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
return StripTags(listHtml).Trim() + Environment.NewLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConvertTables(string html)
|
||||||
|
{
|
||||||
|
return System.Text.RegularExpressions.Regex.Replace(html, @"<table[^>]*>(.*?)</table>", m =>
|
||||||
|
{
|
||||||
|
string tableContent = m.Groups[1].Value;
|
||||||
|
var headerMatch = System.Text.RegularExpressions.Regex.Match(tableContent, @"<tr[^>]*>(.*?)</tr>", System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
if (!headerMatch.Success) return StripTags(tableContent) + Environment.NewLine;
|
||||||
|
|
||||||
|
string headers = CleanInnerHtml(headerMatch.Groups[1].Value);
|
||||||
|
headers = headers.Replace("<th", "<td").Replace("</th>", "</td>");
|
||||||
|
string headerLine = "| " + string.Join(" | ", StripTags(headers).Split(new[] { "</td>", "<td" }, StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim())) + " |";
|
||||||
|
string separator = "|" + string.Join("|", StripTags(headers).Split(new[] { "</td>", "<td" }, StringSplitOptions.RemoveEmptyEntries).Select(c => "---")) + "|";
|
||||||
|
|
||||||
|
var rows = System.Text.RegularExpressions.Regex.Matches(tableContent, @"<tr[^>]*>(.*?)</tr>", System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
var dataRows = new List<string>();
|
||||||
|
for (int i = 1; i < rows.Count; i++)
|
||||||
|
{
|
||||||
|
string rowText = CleanInnerHtml(rows[i].Groups[1].Value);
|
||||||
|
string[] cells = rowText.Split(new[] { "</td>", "<td" }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
dataRows.Add("| " + string.Join(" | ", cells.Select(c => c.Trim().Replace("<b>", "").Replace("</b>", "").Replace("<strong>", "").Replace("</strong>", ""))) + " |");
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerLine + Environment.NewLine + separator + Environment.NewLine + string.Join(Environment.NewLine, dataRows) + Environment.NewLine + Environment.NewLine;
|
||||||
|
}, System.Text.RegularExpressions.RegexOptions.Singleline | System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
CSharpGhostImporter/ImageManager.cs
Normal file
221
CSharpGhostImporter/ImageManager.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
// =============================================================================
|
||||||
|
// ImageManager — Bilder herunterladen, cachen, lokal verwalten
|
||||||
|
// =============================================================================
|
||||||
|
static class ImageManager
|
||||||
|
{
|
||||||
|
private static readonly HttpClient httpClient = new();
|
||||||
|
private static Dictionary<string, (string RelativePath, string LocalPath)> Cache = new();
|
||||||
|
private static List<(string RelativePath, string LocalPath)> FilesToZip = new();
|
||||||
|
private static string LocalImageDir = "";
|
||||||
|
private static string CachePath = "";
|
||||||
|
|
||||||
|
|
||||||
|
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
|
||||||
|
public static bool TryGetRelativePath(string url, out string relativePath)
|
||||||
|
{
|
||||||
|
if (Cache.TryGetValue(url, out var cached))
|
||||||
|
{
|
||||||
|
relativePath = cached.RelativePath;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
relativePath = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Init(string baseDir)
|
||||||
|
{
|
||||||
|
LocalImageDir = Path.Combine(baseDir, "images");
|
||||||
|
CachePath = Path.Combine(baseDir, "imagecache.txt");
|
||||||
|
|
||||||
|
if (!Directory.Exists(LocalImageDir))
|
||||||
|
Directory.CreateDirectory(LocalImageDir);
|
||||||
|
|
||||||
|
LoadCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ImageContainerPath => LocalImageDir;
|
||||||
|
|
||||||
|
public static void SaveCache()
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
foreach (var kvp in Cache)
|
||||||
|
lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}");
|
||||||
|
File.WriteAllLines(CachePath, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearAll()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(LocalImageDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(LocalImageDir, true);
|
||||||
|
Directory.CreateDirectory(LocalImageDir);
|
||||||
|
}
|
||||||
|
Cache.Clear();
|
||||||
|
FilesToZip.Clear();
|
||||||
|
SaveCache();
|
||||||
|
Console.WriteLine("Bilder wurden lokal gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ShowInfo()
|
||||||
|
{
|
||||||
|
if (Cache.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("\nLokal gespeicherte Bilder:");
|
||||||
|
foreach (var kvp in Cache.Take(20))
|
||||||
|
Console.WriteLine($" {kvp.Key} -> images/{kvp.Value.RelativePath}");
|
||||||
|
if (Cache.Count > 20)
|
||||||
|
Console.WriteLine($" ... und {Cache.Count - 20} weitere");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsMainImage(string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(url)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
string fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
// WordPress-Sizes: -300x200, -scaled, -1024x768 etc.
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(fileName, @"-\d+x\d+(?=\.\w+$)"))
|
||||||
|
return false;
|
||||||
|
if (fileName.Contains("-scaled"))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string imageUrl, DateTime? postDate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (Cache.TryGetValue(imageUrl, out var cachedFile))
|
||||||
|
{
|
||||||
|
if (File.Exists(cachedFile.LocalPath))
|
||||||
|
return cachedFile;
|
||||||
|
Cache.Remove(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string fileName = Path.GetFileName(new Uri(imageUrl).AbsolutePath);
|
||||||
|
string urlHash = GetHashCode(imageUrl);
|
||||||
|
var datePrefix = DateTime.Now;
|
||||||
|
string datePath = $"{datePrefix:yyyy}/{datePrefix:MM}";
|
||||||
|
var localPath = Path.Combine(LocalImageDir, datePath);
|
||||||
|
if (!string.IsNullOrEmpty(localPath) && !Directory.Exists(localPath))
|
||||||
|
Directory.CreateDirectory(localPath);
|
||||||
|
|
||||||
|
var counter = 1 + new DirectoryInfo(localPath).GetFiles().Length;
|
||||||
|
var pDate = postDate ?? DateTime.Now;
|
||||||
|
string uniqueFileName = $"{pDate:yyyymmdd}_{counter}{Path.GetExtension(fileName)}";
|
||||||
|
string relativePath = $"{datePath}/{uniqueFileName}";
|
||||||
|
string fallBackPath = $"{datePath}/{uniqueFileName}";
|
||||||
|
localPath = Path.Combine(LocalImageDir, relativePath);
|
||||||
|
fallBackPath = Path.Combine(LocalImageDir, fallBackPath);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (File.Exists(fallBackPath))
|
||||||
|
{
|
||||||
|
File.Move(fallBackPath, localPath);
|
||||||
|
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}");
|
||||||
|
}
|
||||||
|
else if (!File.Exists(localPath))
|
||||||
|
{
|
||||||
|
byte[] imageBytes = await httpClient.GetByteArrayAsync(imageUrl);
|
||||||
|
await File.WriteAllBytesAsync(localPath, imageBytes);
|
||||||
|
Console.WriteLine($" [Bild] Heruntergeladen: {relativePath}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
ResizeImage(localPath);
|
||||||
|
|
||||||
|
var fileResult = (relativePath, localPath);
|
||||||
|
Cache[imageUrl] = fileResult;
|
||||||
|
FilesToZip.Add(fileResult);
|
||||||
|
return fileResult;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Bild-Fehler] Konnte {imageUrl} nicht laden: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ResizeImage(string localPath)
|
||||||
|
{
|
||||||
|
const int maxSize = 1024;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var image = Image.Load(localPath);
|
||||||
|
int width = image.Width;
|
||||||
|
int height = image.Height;
|
||||||
|
|
||||||
|
int longEdge = Math.Max(width, height);
|
||||||
|
if (longEdge <= maxSize) return;
|
||||||
|
|
||||||
|
int newWidth, newHeight;
|
||||||
|
if (width >= height)
|
||||||
|
{
|
||||||
|
newWidth = maxSize;
|
||||||
|
newHeight = (int)Math.Round(height * ((double)maxSize / width));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newHeight = maxSize;
|
||||||
|
newWidth = (int)Math.Round(width * ((double)maxSize / height));
|
||||||
|
}
|
||||||
|
|
||||||
|
image.Mutate(x => x.Resize(newWidth, newHeight));
|
||||||
|
image.Save(localPath);
|
||||||
|
Console.WriteLine($" [Bild] Angepasst: {width}x{height} → {newWidth}x{newHeight}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Bild-Warnung] Resize fehlgeschlagen für {Path.GetFileName(localPath)}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetHashCode(string input)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
int hash = 17;
|
||||||
|
foreach (char c in input)
|
||||||
|
hash = hash * 23 + c;
|
||||||
|
return hash.ToString("x8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LoadCache()
|
||||||
|
{
|
||||||
|
if (!File.Exists(CachePath)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(CachePath);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var parts = line.Split('|');
|
||||||
|
if (parts.Length == 3)
|
||||||
|
Cache[parts[0]] = (parts[1], parts[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fehler beim Laden des Bild-Caches: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
487
CSharpGhostImporter/PostFetcher.cs
Normal file
487
CSharpGhostImporter/PostFetcher.cs
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
// =============================================================================
|
||||||
|
// PostFetcher — Posts von WordPress abrufen, verarbeiten
|
||||||
|
// =============================================================================
|
||||||
|
static class PostFetcher
|
||||||
|
{
|
||||||
|
private static readonly HttpClient httpClient = new();
|
||||||
|
private static HashSet<int> ProcessedPosts = new();
|
||||||
|
private static List<GhostPost> GhostPosts = new();
|
||||||
|
private static string ProcessedPostsPath = "";
|
||||||
|
//private static string MarkdownDir = "";
|
||||||
|
private static string GhostPostsDir = "";
|
||||||
|
private static string BaseDir = "";
|
||||||
|
|
||||||
|
public static int ProcessedCount => ProcessedPosts.Count;
|
||||||
|
public static int PostCount => GhostPosts.Count;
|
||||||
|
public static IReadOnlyList<GhostPost> Posts => GhostPosts;
|
||||||
|
|
||||||
|
public static void Init(string baseDir)
|
||||||
|
{
|
||||||
|
BaseDir = baseDir;
|
||||||
|
ProcessedPostsPath = Path.Combine(baseDir, "processed_posts.txt");
|
||||||
|
//MarkdownDir = Path.Combine(baseDir, "markdown");
|
||||||
|
GhostPostsDir = Path.Combine(baseDir, "ghostPosts");
|
||||||
|
|
||||||
|
////if (!Directory.Exists(MarkdownDir))
|
||||||
|
//// Directory.CreateDirectory(MarkdownDir);
|
||||||
|
if (!Directory.Exists(GhostPostsDir))
|
||||||
|
Directory.CreateDirectory(GhostPostsDir);
|
||||||
|
|
||||||
|
LoadProcessedPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearProcessedPosts()
|
||||||
|
{
|
||||||
|
ProcessedPosts.Clear();
|
||||||
|
File.WriteAllText(ProcessedPostsPath, "wp_id,wp_slug,ghost_id" + Environment.NewLine);
|
||||||
|
Console.WriteLine("Abgefragte Posts wurden zurückgesetzt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ShowInfo()
|
||||||
|
{
|
||||||
|
if (ProcessedPosts.Count > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Letzte abgefragte Objekte: {string.Join(", ", ProcessedPosts.OrderByDescending(x => x).Take(10))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task FetchFromWordPressAsync(string wpBaseUrl, int maxPosts)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Starte Abruf von WordPress...");
|
||||||
|
|
||||||
|
string wpHost = "";
|
||||||
|
try { wpHost = new Uri(wpBaseUrl).Host.ToLowerInvariant(); } catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allPostIds = new List<int>();
|
||||||
|
var postTypes = new[] { "post", "page" };
|
||||||
|
|
||||||
|
foreach (var postType in postTypes)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Hole {postType}-IDs von der WordPress-API...");
|
||||||
|
string idsUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/posts-ids?post_type={postType}";
|
||||||
|
var idsResponse = await httpClient.GetStringAsync(idsUrl);
|
||||||
|
var idsObj = JsonSerializer.Deserialize<IdsResponse>(idsResponse);
|
||||||
|
if (idsObj?.Ids != null)
|
||||||
|
allPostIds.AddRange(idsObj.Ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPostIds.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Keine Beiträge oder Seiten gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingIds = allPostIds.Where(id => !ProcessedPosts.Contains(id)).ToList();
|
||||||
|
Console.WriteLine($"Insgesamt gefunden: {allPostIds.Count}, Bereits abgefragt: {allPostIds.Count - pendingIds.Count}, Noch zu verarbeiten: {pendingIds.Count}");
|
||||||
|
|
||||||
|
if (pendingIds.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Alle Beiträge und Seiten wurden bereits abgefragt. Nichts zu tun. (Nutze Option 5 zum Zurücksetzen)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxPosts > 0)
|
||||||
|
pendingIds = pendingIds.Take(maxPosts).ToList();
|
||||||
|
|
||||||
|
Console.WriteLine($"Es werden {pendingIds.Count} Beiträge/Seiten verarbeitet.");
|
||||||
|
|
||||||
|
int internalId = 1;
|
||||||
|
if (GhostPosts.Count > 0)
|
||||||
|
{
|
||||||
|
var lastId = GhostPosts.Select(gp => int.TryParse(gp.Id, out int id) ? id : 0).Max();
|
||||||
|
internalId = lastId + 1;
|
||||||
|
}
|
||||||
|
int processed = 0;
|
||||||
|
|
||||||
|
foreach (int wpId in pendingIds)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}...");
|
||||||
|
string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detailJson = await httpClient.GetStringAsync(detailUrl);
|
||||||
|
var wpPost = JsonSerializer.Deserialize<WpPostResponse>(detailJson);
|
||||||
|
|
||||||
|
if (wpPost == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" Übersprungen (null).");
|
||||||
|
MarkProcessed(wpId, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string htmlContent = wpPost.Content ?? "";
|
||||||
|
var parsedDate = DateTime.MinValue;
|
||||||
|
var postDate = DateTime.TryParse(wpPost.Date, out parsedDate) ? parsedDate : new DateTime(2026, 9, 1);
|
||||||
|
|
||||||
|
// Alle internen Bild-URLs aus dem HTML extrahieren (keine externen Links)
|
||||||
|
var htmlImageUrls = ExtractImageUrls(htmlContent, wpHost);
|
||||||
|
|
||||||
|
// Bevorzugte Media-Dateien ermitteln
|
||||||
|
var preferredMedia = GetPreferredMediaFiles(wpPost.MediaFiles, htmlImageUrls, wpHost);
|
||||||
|
|
||||||
|
// Prüfe: Referenzierte Bilder im HTML, die NICHT in MediaFiles sind
|
||||||
|
if (htmlImageUrls.Count > 0 && wpPost.MediaFiles != null)
|
||||||
|
{
|
||||||
|
var mediaFileNames = wpPost.MediaFiles.Select(m => GetFileNameFromUrl(m).ToLowerInvariant()).ToHashSet();
|
||||||
|
foreach (var imgUrl in htmlImageUrls)
|
||||||
|
{
|
||||||
|
string imgFileName = GetFileNameFromUrl(imgUrl).ToLowerInvariant();
|
||||||
|
if (!mediaFileNames.Contains(imgFileName))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Warnung] Bild im HTML nicht in MediaFiles: {Path.GetFileName(new Uri(imgUrl).AbsolutePath)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredMedia.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (string mediaUrl in preferredMedia)
|
||||||
|
{
|
||||||
|
var downloaded = await ImageManager.DownloadAsync(mediaUrl, postDate);
|
||||||
|
if (downloaded.HasValue)
|
||||||
|
{
|
||||||
|
string ghostMediaPath = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}";
|
||||||
|
htmlContent = htmlContent.Replace(mediaUrl, ghostMediaPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WordPress HTML zu Ghost-kompatibles HTML konvertieren (Galerien, Figures, etc.)
|
||||||
|
htmlContent = HtmlGhostConverter.Convert(htmlContent);
|
||||||
|
|
||||||
|
// Lokale Bildpfade für JSON-Datei sammeln
|
||||||
|
var imageFiles = new List<string>();
|
||||||
|
if (preferredMedia.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (string mediaUrl in preferredMedia)
|
||||||
|
{
|
||||||
|
if (ImageManager.TryGetRelativePath(mediaUrl, out string relPath))
|
||||||
|
imageFiles.Add(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Featured Image verarbeiten
|
||||||
|
string featureImageGhostPath = "";
|
||||||
|
if (!string.IsNullOrEmpty(wpPost.FeaturedImage))
|
||||||
|
{
|
||||||
|
if (ImageManager.TryGetRelativePath(wpPost.FeaturedImage, out string featRel))
|
||||||
|
{
|
||||||
|
featureImageGhostPath = $"__GHOST_URL__/content/images/{featRel}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var featDownload = await ImageManager.DownloadAsync(wpPost.FeaturedImage, postDate);
|
||||||
|
if (featDownload.HasValue)
|
||||||
|
{
|
||||||
|
featureImageGhostPath = $"__GHOST_URL__/content/images/{featDownload.Value.RelativePath}";
|
||||||
|
if (!imageFiles.Contains(featDownload.Value.RelativePath))
|
||||||
|
imageFiles.Add(featDownload.Value.RelativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ghostPost = new GhostPost
|
||||||
|
{
|
||||||
|
Id = internalId.ToString(),
|
||||||
|
Title = wpPost.Title,
|
||||||
|
Slug = wpPost.Slug,
|
||||||
|
Html = htmlContent,
|
||||||
|
Status = "published",
|
||||||
|
CreatedAt = postDate.ToString("o"),
|
||||||
|
PublishedAt = postDate.ToString("o")
|
||||||
|
};
|
||||||
|
|
||||||
|
GhostPosts.Add(ghostPost);
|
||||||
|
|
||||||
|
// JSON-Datei in ghostPosts/ speichern
|
||||||
|
var postFile = new GhostPostFile
|
||||||
|
{
|
||||||
|
WpId = wpId,
|
||||||
|
WpSlug = wpPost.Slug,
|
||||||
|
GhostId = ghostPost.Id,
|
||||||
|
Title = wpPost.Title,
|
||||||
|
Slug = wpPost.Slug,
|
||||||
|
Excerpt = wpPost.Excerpt,
|
||||||
|
Status = "published",
|
||||||
|
CreatedAt = postDate.ToString("o"),
|
||||||
|
PublishedAt = postDate.ToString("o"),
|
||||||
|
FeatureImage = featureImageGhostPath,
|
||||||
|
Categories = wpPost.Categories ?? new(),
|
||||||
|
Keywords = wpPost.Keywords ?? "",
|
||||||
|
Comments = wpPost.Comments ?? new(),
|
||||||
|
ImageFiles = imageFiles,
|
||||||
|
Html = htmlContent
|
||||||
|
};
|
||||||
|
string jsonPath = Path.Combine(GhostPostsDir, $"{postFile.WpId}.json");
|
||||||
|
File.WriteAllText(jsonPath, JsonSerializer.Serialize(postFile, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|
||||||
|
MarkProcessed(wpId, wpPost.Slug);
|
||||||
|
internalId++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" Fehler beim Abrufen von ID {wpId}: {ex.Message}");
|
||||||
|
MarkProcessed(wpId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
Console.WriteLine($" Abruf abgeschlossen! {GhostPosts.Count} Beiträge geladen.");
|
||||||
|
Console.WriteLine($" Abgefragte Posts insgesamt: {ProcessedPosts.Count}");
|
||||||
|
Console.WriteLine(" Nutze Option 2 um die Ghost ZIP zu erstellen.");
|
||||||
|
Console.WriteLine("=========================================");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\nFehler beim Abruf: {ex.Message}");
|
||||||
|
Console.WriteLine($"Stack Trace: {ex.StackTrace}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//public static void SaveAllMarkdownFiles()
|
||||||
|
//{
|
||||||
|
// foreach (var post in GhostPosts)
|
||||||
|
// {
|
||||||
|
// if (string.IsNullOrEmpty(post.Slug)) continue;
|
||||||
|
// string mdPath = Path.Combine(MarkdownDir, $"{post.Slug}.md");
|
||||||
|
// if (!string.IsNullOrEmpty(post.Markdown))
|
||||||
|
// File.WriteAllText(mdPath, post.Markdown);
|
||||||
|
// }
|
||||||
|
// Console.WriteLine($"Markdown-Dateien gespeichert: {GhostPosts.Count} Dateien unter {MarkdownDir}");
|
||||||
|
//}
|
||||||
|
|
||||||
|
private static List<string> ExtractImageUrls(string html, string wpHost)
|
||||||
|
{
|
||||||
|
var urls = new List<string>();
|
||||||
|
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" };
|
||||||
|
|
||||||
|
// 1) <img src="..."> extrahieren
|
||||||
|
var imgMatches = System.Text.RegularExpressions.Regex.Matches(html, @"<img\s+[^>]*src=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
foreach (System.Text.RegularExpressions.Match m in imgMatches)
|
||||||
|
{
|
||||||
|
string url = m.Groups[1].Value;
|
||||||
|
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||||
|
urls.Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) data-orig-file (Vollbild) extrahieren
|
||||||
|
var origFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-orig-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
foreach (System.Text.RegularExpressions.Match m in origFileMatches)
|
||||||
|
{
|
||||||
|
string url = m.Groups[1].Value;
|
||||||
|
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||||
|
urls.Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) data-large-file extrahieren
|
||||||
|
var largeFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-large-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
foreach (System.Text.RegularExpressions.Match m in largeFileMatches)
|
||||||
|
{
|
||||||
|
string url = m.Groups[1].Value;
|
||||||
|
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||||
|
urls.Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) srcset (größte Variante pro img) extrahieren
|
||||||
|
var srcsetMatches = System.Text.RegularExpressions.Regex.Matches(html, @"srcset=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
foreach (System.Text.RegularExpressions.Match m in srcsetMatches)
|
||||||
|
{
|
||||||
|
string srcset = m.Groups[1].Value;
|
||||||
|
string widestUrl = GetWidestFromSrcset(srcset);
|
||||||
|
if (!string.IsNullOrEmpty(widestUrl) && IsInternalUrl(widestUrl, wpHost) && !urls.Contains(widestUrl))
|
||||||
|
urls.Add(widestUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) <a href="..."> mit Bildendung (Figure-Links auf Originalbild)
|
||||||
|
var aMatches = System.Text.RegularExpressions.Regex.Matches(html, @"<a\s+[^>]*href=[""']([^""']+)[""'][^>]*>", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
foreach (System.Text.RegularExpressions.Match m in aMatches)
|
||||||
|
{
|
||||||
|
string url = m.Groups[1].Value;
|
||||||
|
if (string.IsNullOrEmpty(url)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string ext = Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant();
|
||||||
|
if (imageExts.Contains(ext) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
|
||||||
|
urls.Add(url);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetWidestFromSrcset(string srcset)
|
||||||
|
{
|
||||||
|
string bestUrl = "";
|
||||||
|
int bestWidth = 0;
|
||||||
|
foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
string[] tokens = part.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (tokens.Length < 2) continue;
|
||||||
|
if (int.TryParse(tokens[1].Replace("w", ""), out int w) && w > bestWidth)
|
||||||
|
{
|
||||||
|
bestWidth = w;
|
||||||
|
bestUrl = tokens[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInternalUrl(string url, string wpHost)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(wpHost)) return true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
return uri.Host.ToLowerInvariant().EndsWith(wpHost) || wpHost.EndsWith(uri.Host.ToLowerInvariant());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return url.StartsWith("/") || url.StartsWith("wp-content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeImageUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
string path = uri.AbsolutePath;
|
||||||
|
// Entferne -scaled UND Dimensionspostfixe für den Vergleich
|
||||||
|
path = System.Text.RegularExpressions.Regex.Replace(path, @"-scaled(?=\.\w+$)", "");
|
||||||
|
path = System.Text.RegularExpressions.Regex.Replace(path, @"-\d+x\d+(?=\.\w+$)", "");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFileNameFromUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Path.GetFileName(new Uri(url).AbsolutePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetPreferredMediaFiles(List<string>? mediaFiles, List<string> htmlImageUrls, string wpHost)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" };
|
||||||
|
|
||||||
|
// Alle internen Bild-URLs zusammenführen: MediaFiles + HTML-referenzierte
|
||||||
|
var allUrls = new List<string>();
|
||||||
|
if (mediaFiles != null)
|
||||||
|
allUrls.AddRange(mediaFiles.Where(u => IsInternalUrl(u, wpHost)));
|
||||||
|
foreach (var u in htmlImageUrls)
|
||||||
|
{
|
||||||
|
if (!allUrls.Contains(u))
|
||||||
|
allUrls.Add(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allUrls.Count == 0)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// Dateinamen aus den HTML-Links extrahieren
|
||||||
|
var htmlFileNames = htmlImageUrls.Select(u => GetFileNameFromUrl(u).ToLowerInvariant()).ToHashSet();
|
||||||
|
|
||||||
|
// Gruppiere nach normalisiertem Dateinamen (ohne -scaled und Dimensionspostfixe)
|
||||||
|
var grouped = new Dictionary<string, List<string>>();
|
||||||
|
foreach (var url in allUrls)
|
||||||
|
{
|
||||||
|
string normalized = NormalizeImageUrl(url);
|
||||||
|
if (!grouped.ContainsKey(normalized))
|
||||||
|
grouped[normalized] = new List<string>();
|
||||||
|
grouped[normalized].Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in grouped)
|
||||||
|
{
|
||||||
|
// Nur Hauptbilder berücksichtigen (keine WordPress-Sizes wie -300x200, keine -scaled)
|
||||||
|
var mainImages = group.Value.Where(u => ImageManager.IsMainImage(u)).ToList();
|
||||||
|
if (mainImages.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [Bild] Übersprungen (kein Hauptbild): {Path.GetFileName(new Uri(@group.Value[0]).AbsolutePath)}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainImages.Count == 1)
|
||||||
|
{
|
||||||
|
result.Add(mainImages[0]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Prüfe ob eine der Varianten als <a href> im HTML referenziert wird
|
||||||
|
string? linked = mainImages.FirstOrDefault(u => htmlFileNames.Contains(GetFileNameFromUrl(u).ToLowerInvariant()));
|
||||||
|
|
||||||
|
if (linked != null)
|
||||||
|
{
|
||||||
|
result.Add(linked);
|
||||||
|
var skipped = mainImages.Where(u => u != linked).ToList();
|
||||||
|
foreach (var s in skipped)
|
||||||
|
Console.WriteLine($" [Bild] Übersprungen (nicht verlinkt): {Path.GetFileName(new Uri(s).AbsolutePath)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Keine Verlinkung: Original (ohne -scaled) bevorzugen
|
||||||
|
var preferred = mainImages.FirstOrDefault(u => !u.Contains("-scaled")) ?? mainImages[0];
|
||||||
|
result.Add(preferred);
|
||||||
|
var skipped = mainImages.Where(u => u != preferred).ToList();
|
||||||
|
foreach (var s in skipped)
|
||||||
|
Console.WriteLine($" [Bild] Übersprungen (scaled): {Path.GetFileName(new Uri(s).AbsolutePath)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MarkProcessed(int wpId, string? wpSlug = null)
|
||||||
|
{
|
||||||
|
ProcessedPosts.Add(wpId);
|
||||||
|
SaveProcessedPosts(wpId, wpSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LoadProcessedPosts()
|
||||||
|
{
|
||||||
|
if (!File.Exists(ProcessedPostsPath)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(ProcessedPostsPath);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("wp_id")) continue;
|
||||||
|
var parts = line.Split(',');
|
||||||
|
if (parts.Length >= 1 && int.TryParse(parts[0].Trim(), out int postId))
|
||||||
|
ProcessedPosts.Add(postId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fehler beim Laden der abgefragten Posts: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveProcessedPosts(int wpId, string? wpSlug)
|
||||||
|
{
|
||||||
|
string entry = $"{wpId},{wpSlug ?? ""}";
|
||||||
|
using var writer = File.AppendText(ProcessedPostsPath);
|
||||||
|
writer.WriteLine(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
CSharpGhostImporter/WPDto.cs
Normal file
48
CSharpGhostImporter/WPDto.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace wp2ghost
|
||||||
|
{
|
||||||
|
public class IdsResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("count")] public int Count { get; set; }
|
||||||
|
[JsonPropertyName("post_type")] public string? PostType { get; set; }
|
||||||
|
[JsonPropertyName("ids")] public List<int>? Ids { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WpPostResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public int Id { get; set; }
|
||||||
|
[JsonPropertyName("title")] public string? Title { get; set; }
|
||||||
|
[JsonPropertyName("slug")] public string? Slug { get; set; }
|
||||||
|
[JsonPropertyName("date")] public string? Date { get; set; }
|
||||||
|
[JsonPropertyName("content")] public string? Content { get; set; }
|
||||||
|
[JsonPropertyName("excerpt")] public string? Excerpt { get; set; }
|
||||||
|
[JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; }
|
||||||
|
[JsonPropertyName("categories")] public List<WpCategory>? Categories { get; set; } = new();
|
||||||
|
[JsonPropertyName("keywords")] public string? Keywords { get; set; }
|
||||||
|
[JsonPropertyName("comments")] public List<WpComment>? Comments { get; set; } = new();
|
||||||
|
[JsonPropertyName("media_files")] public List<string>? MediaFiles { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WpCategory
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public int Id { get; set; }
|
||||||
|
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||||
|
[JsonPropertyName("slug")] public string? Slug { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WpComment
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public int Id { get; set; }
|
||||||
|
[JsonPropertyName("author_name")] public string? AuthorName { get; set; }
|
||||||
|
[JsonPropertyName("author_email")] public string? AuthorEmail { get; set; }
|
||||||
|
[JsonPropertyName("author_url")] public string? AuthorUrl { get; set; }
|
||||||
|
[JsonPropertyName("content")] public string? Content { get; set; }
|
||||||
|
[JsonPropertyName("date")] public string? Date { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
6
CSharpGhostImporter/config.txt
Normal file
6
CSharpGhostImporter/config.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# WP2Ghost Konfiguration
|
||||||
|
# Ändere die Werte und starte das Programm neu
|
||||||
|
|
||||||
|
wpBaseUrl=https://deine-domain.com
|
||||||
|
outputZipPath=ghost_import.zip
|
||||||
|
maxPosts=-1
|
||||||
3
CSharpGhostImporter/imagecache.txt
Normal file
3
CSharpGhostImporter/imagecache.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Bild-Cache für WP2Ghost
|
||||||
|
# Format: imageUrl|relativePath|localTempPath
|
||||||
|
# Automatisch vom Programm generiert. Nicht manuell bearbeiten.
|
||||||
3
CSharpGhostImporter/processed_posts.txt
Normal file
3
CSharpGhostImporter/processed_posts.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Abgefragte Posts für WP2Ghost
|
||||||
|
# Eine Post-ID pro Zeile
|
||||||
|
# Automatisch vom Programm generiert. Nicht manuell bearbeiten.
|
||||||
105
CSharpGhostImporter/test_gallery.html
Normal file
105
CSharpGhostImporter/test_gallery.html
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<p>Am letzten Karnevalwochenende 2017 haben wir Venedig besucht.</p>
|
||||||
|
<p>Trotz der Massen und teilweise langen Wartenzeiten, dank der Terrorprävention, war es ein schönes Wochenende.</p>
|
||||||
|
<p>Hier ein paar Impressionen.</p>
|
||||||
|
<div id='gallery-1' class='gallery galleryid-0 gallery-columns-3 gallery-size-thumbnail'>
|
||||||
|
<figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00594/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00594-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="45" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00594/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00594.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488112702","copyright":"","focal_length":"92","iso":"125","shutter_speed":"0.0025","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00594" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00594.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00576/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00576-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="44" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00576/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00576.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488111512","copyright":"","focal_length":"97","iso":"125","shutter_speed":"0.00125","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00576" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00576.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00573/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00573-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="43" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00573/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00573.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488111458","copyright":"","focal_length":"90","iso":"125","shutter_speed":"0.002","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00573" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00573.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00507/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00507-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="42" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00507/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00507.jpg" data-orig-size="640,474" data-comments-opened="1" data-image-meta="{"aperture":"4.5","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488109764","copyright":"","focal_length":"198","iso":"125","shutter_speed":"0.003125","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00507" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00507.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00506/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00506-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="41" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00506/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00506.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"4.5","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488109732","copyright":"","focal_length":"200","iso":"125","shutter_speed":"0.002","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00506" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00506.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00470/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00470-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="38" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00470/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00470.jpg" data-orig-size="640,615" data-comments-opened="1" data-image-meta="{"aperture":"4.5","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488105959","copyright":"","focal_length":"70","iso":"100","shutter_speed":"0.0015625","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00470" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00470.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00459/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00459-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="37" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00459/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00459.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488105145","copyright":"","focal_length":"70","iso":"100","shutter_speed":"0.00125","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00459" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00459.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00457/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00457-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="36" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00457/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00457.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488105126","copyright":"","focal_length":"70","iso":"100","shutter_speed":"0.00125","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00457" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00457.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00382/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00382-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="35" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00382/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00382.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488046499","copyright":"","focal_length":"70","iso":"400","shutter_speed":"0.05","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00382" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00382.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00365/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00365-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="34" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00365/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00365.jpg" data-orig-size="416,640" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488039926","copyright":"","focal_length":"177","iso":"640","shutter_speed":"0.00125","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00365" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00365.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00349/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00349-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="33" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00349/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00349.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488038048","copyright":"","focal_length":"88","iso":"640","shutter_speed":"0.00015625","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00349" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00349.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00338/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00338-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="32" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00338/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00338.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488037878","copyright":"","focal_length":"70","iso":"640","shutter_speed":"0.0005","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00338" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00338.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00320/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00320-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="31" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00320/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00320.jpg" data-orig-size="476,640" data-comments-opened="1" data-image-meta="{"aperture":"5.6","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488035411","copyright":"","focal_length":"189","iso":"50","shutter_speed":"0.0025","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00320" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00320.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00305/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00305-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" srcset="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00305-150x150.jpg 150w, https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00305-300x300.jpg 300w, https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00305.jpg 640w" sizes="auto, (max-width: 150px) 100vw, 150px" data-attachment-id="30" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00305/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00305.jpg" data-orig-size="640,640" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488034750","copyright":"","focal_length":"77","iso":"64","shutter_speed":"0.01","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00305" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00305.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00298/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00298-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="29" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00298/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00298.jpg" data-orig-size="640,467" data-comments-opened="1" data-image-meta="{"aperture":"5","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488034170","copyright":"","focal_length":"70","iso":"64","shutter_speed":"0.004","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00298" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00298.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00294/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00294-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="28" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00294/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00294.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"4.5","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488034129","copyright":"","focal_length":"107","iso":"50","shutter_speed":"0.001","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00294" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00294.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00291/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00291-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="27" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00291/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00291.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488033907","copyright":"","focal_length":"93","iso":"50","shutter_speed":"0.0025","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00291" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00291.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00279/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00279-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="25" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00279/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00279.jpg" data-orig-size="461,640" data-comments-opened="1" data-image-meta="{"aperture":"4","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488033695","copyright":"","focal_length":"70","iso":"50","shutter_speed":"0.00125","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00279" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00279.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00276/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00276-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="24" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00276/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00276.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"7.1","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488033461","copyright":"","focal_length":"200","iso":"50","shutter_speed":"0.00625","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00276" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00276.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00271/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00271-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="23" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00271/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00271.jpg" data-orig-size="427,640" data-comments-opened="1" data-image-meta="{"aperture":"7.1","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488033249","copyright":"","focal_length":"70","iso":"50","shutter_speed":"0.008","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00271" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00271.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon portrait'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00270/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00270-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="22" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00270/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00270.jpg" data-orig-size="460,640" data-comments-opened="1" data-image-meta="{"aperture":"7.1","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488033156","copyright":"","focal_length":"113","iso":"50","shutter_speed":"0.008","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00270" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00270.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00257/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00257-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="21" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00257/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00257.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488031611","copyright":"","focal_length":"70","iso":"50","shutter_speed":"0.005","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00257" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00257.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00255/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00255-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="20" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00255/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00255.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488031407","copyright":"","focal_length":"97","iso":"50","shutter_speed":"0.002","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00255" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00255.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure><figure class='gallery-item'>
|
||||||
|
<div class='gallery-icon landscape'>
|
||||||
|
<a href='https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00253/'><img loading="lazy" decoding="async" width="150" height="150" src="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00253-150x150.jpg" class="attachment-thumbnail size-thumbnail" alt="" data-attachment-id="19" data-permalink="https://www.brigitteundrolandunterwegs.com/erster-blogbeitrag/dsc00253/" data-orig-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00253.jpg" data-orig-size="640,427" data-comments-opened="1" data-image-meta="{"aperture":"8","credit":"","camera":"ILCE-7M2","caption":"","created_timestamp":"1488031351","copyright":"","focal_length":"111","iso":"50","shutter_speed":"0.004","title":"","orientation":"0","lens":"FE 70-200mm F4 G OSS"}" data-image-title="DSC00253" data-image-description="" data-image-caption="" data-large-file="https://www.brigitteundrolandunterwegs.com/wp-content/uploads/2017/03/dsc00253.jpg" /></a>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p> </p>
|
||||||
|
<p> </p>
|
||||||
3
CSharpGhostImporter/wp2ghost.slnx
Normal file
3
CSharpGhostImporter/wp2ghost.slnx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<Solution>
|
||||||
|
<Project Path="CSharpGhostImporter.csproj" />
|
||||||
|
</Solution>
|
||||||
396
WPPlugin/rbrExportRestApi.php
Normal file
396
WPPlugin/rbrExportRestApi.php
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: RBR Export to Ghost
|
||||||
|
* Plugin URI:
|
||||||
|
* Description: Exportiert WordPress-Beiträge und Medien über eine REST-API für den Import in Ghost CMS.
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Author: Roland Brunner
|
||||||
|
* Author URI:
|
||||||
|
* License: GPL-2.0+
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.txt
|
||||||
|
* Text Domain: rbr-export-ghost
|
||||||
|
* Domain Path: /languages
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
define('RBR_EXPORT_VERSION', '1.0.0');
|
||||||
|
define('RBR_EXPORT_PATH', plugin_dir_path(__FILE__));
|
||||||
|
define('RBR_EXPORT_URL', plugin_dir_url(__FILE__));
|
||||||
|
|
||||||
|
register_activation_hook(__FILE__, 'rbr_export_activate');
|
||||||
|
register_deactivation_hook(__FILE__, 'rbr_export_deactivate');
|
||||||
|
|
||||||
|
function rbr_export_activate() {
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rbr_export_deactivate() {
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitisiert den per_page-Wert und bewahrt -1 für "alle".
|
||||||
|
*/
|
||||||
|
function rbr_sanitize_per_page($value) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
// 1. Endpunkt: Alle Post-IDs abrufen
|
||||||
|
register_rest_route('custom/v1', '/posts-ids', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'rbr_get_all_post_ids',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'args' => array(
|
||||||
|
'post_type' => array(
|
||||||
|
'default' => 'post',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
),
|
||||||
|
'per_page' => array(
|
||||||
|
'default' => -1,
|
||||||
|
'sanitize_callback' => 'rbr_sanitize_per_page',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 2. Endpunkt: Content & Medien für eine bestimmte Post-ID
|
||||||
|
register_rest_route('custom/v1', '/post-details/(?P<id>\d+)', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'rbr_get_post_details_with_media',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
));
|
||||||
|
|
||||||
|
// 3. Endpunkt: Kategorien/Tags für alle Beiträge
|
||||||
|
register_rest_route('custom/v1', '/post-taxonomies/(?P<id>\d+)', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'rbr_get_post_taxonomies',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
));
|
||||||
|
|
||||||
|
// 4. Endpunkt: Alle Medien-Dateien (Attachments) abrufen
|
||||||
|
register_rest_route('custom/v1', '/media', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'rbr_get_all_media',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'args' => array(
|
||||||
|
'per_page' => array(
|
||||||
|
'default' => -1,
|
||||||
|
'sanitize_callback' => 'rbr_sanitize_per_page',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 5. Endpunkt: Medien-Datei direkt über ID abrufen
|
||||||
|
register_rest_route('custom/v1', '/media/(?P<id>\d+)', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'rbr_get_media_by_id',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein Array mit allen veröffentlichten Post-IDs.
|
||||||
|
* Unterstützt verschiedene Post-Types (post, page, custom).
|
||||||
|
*/
|
||||||
|
function rbr_get_all_post_ids($request) {
|
||||||
|
$post_type = sanitize_text_field($request['post_type']);
|
||||||
|
$per_page = rbr_sanitize_per_page($request['per_page']);
|
||||||
|
|
||||||
|
$args = array(
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'numberposts' => $per_page,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'ASC',
|
||||||
|
);
|
||||||
|
|
||||||
|
$post_ids = get_posts($args);
|
||||||
|
|
||||||
|
if (empty($post_ids)) {
|
||||||
|
return new WP_Error('no_posts', 'Keine Beiträge gefunden.', array('status' => 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response(array(
|
||||||
|
'count' => count($post_ids),
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'ids' => $post_ids,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert Content, Featured Image, Kategorien, Keywords, Comments und alle im HTML/Post genutzten Medien-URLs.
|
||||||
|
*/
|
||||||
|
function rbr_get_post_details_with_media($data) {
|
||||||
|
$post_id = intval($data['id']);
|
||||||
|
$post = get_post($post_id);
|
||||||
|
|
||||||
|
if (!$post || $post->post_status !== 'publish') {
|
||||||
|
return new WP_Error('no_post', 'Beitrag nicht gefunden.', array('status' => 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = apply_filters('the_content', $post->post_content);
|
||||||
|
$media_urls = array();
|
||||||
|
|
||||||
|
// 1. Featured Image (Titelbild) auslesen
|
||||||
|
$featured_image_url = '';
|
||||||
|
$featured_image_id = get_post_thumbnail_id($post_id);
|
||||||
|
if ($featured_image_id) {
|
||||||
|
$featured_image_url = wp_get_attachment_url($featured_image_id);
|
||||||
|
if ($featured_image_url) {
|
||||||
|
$media_urls[] = $featured_image_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Inhaltsbilder aus dem HTML extrahieren (via Regex für <img> Tags)
|
||||||
|
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $post->post_content, $matches);
|
||||||
|
if (!empty($matches[1])) {
|
||||||
|
foreach ($matches[1] as $img_url) {
|
||||||
|
if (filter_var($img_url, FILTER_VALIDATE_URL)) {
|
||||||
|
$media_urls[] = $img_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Attachments die mit dem Post verknüpft sind
|
||||||
|
$attachments = get_posts(array(
|
||||||
|
'post_type' => 'attachment',
|
||||||
|
'numberposts' => -1,
|
||||||
|
'post_status' => null,
|
||||||
|
'post_parent' => $post_id,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'post_mime_type' => 'image',
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($attachments as $att_id) {
|
||||||
|
$att_url = wp_get_attachment_url($att_id);
|
||||||
|
if ($att_url) {
|
||||||
|
$media_urls[] = $att_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikate entfernen
|
||||||
|
$unique_media = array_values(array_unique($media_urls));
|
||||||
|
|
||||||
|
// 4. Kategorien ( Categories Only, keine Tags)
|
||||||
|
$categories = array();
|
||||||
|
$cats = get_the_category($post_id);
|
||||||
|
if (!empty($cats) && !is_wp_error($cats)) {
|
||||||
|
foreach ($cats as $cat) {
|
||||||
|
$categories[] = array(
|
||||||
|
'id' => intval($cat->term_id),
|
||||||
|
'name' => $cat->name,
|
||||||
|
'slug' => $cat->slug,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Keywords (aus Yoast SEO oder Rank Math oder Custom Field)
|
||||||
|
$keywords = '';
|
||||||
|
// Yoast SEO
|
||||||
|
if (function_exists('yoast_get_primary_term')) {
|
||||||
|
$keywords = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
|
||||||
|
}
|
||||||
|
// Rank Math
|
||||||
|
if (empty($keywords) && function_exists('rank_math_get_single_post_data')) {
|
||||||
|
$rank_data = rank_math_get_single_post_data($post_id);
|
||||||
|
if (!empty($rank_data->get_keywords())) {
|
||||||
|
$keywords = $rank_data->get_keywords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: Custom Field 'post_keywords'
|
||||||
|
if (empty($keywords)) {
|
||||||
|
$keywords = get_post_meta($post_id, 'post_keywords', true);
|
||||||
|
}
|
||||||
|
// Fallback: Kategorien als Keywords
|
||||||
|
if (empty($keywords) && !empty($categories)) {
|
||||||
|
$keywords = implode(', ', array_column($categories, 'name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Kommentare
|
||||||
|
$comments = array();
|
||||||
|
$wp_comments = get_comments(array(
|
||||||
|
'post_id' => $post_id,
|
||||||
|
'status' => 'approve',
|
||||||
|
'number' => 100,
|
||||||
|
));
|
||||||
|
foreach ($wp_comments as $comment) {
|
||||||
|
$comments[] = array(
|
||||||
|
'id' => intval($comment->comment_ID),
|
||||||
|
'author_name' => $comment->comment_author,
|
||||||
|
'author_email' => $comment->comment_author_email,
|
||||||
|
'author_url' => $comment->comment_author_url,
|
||||||
|
'content' => $comment->comment_content,
|
||||||
|
'date' => mysql_to_rfc3339($comment->comment_date),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = array(
|
||||||
|
'id' => intval($post->ID),
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'slug' => $post->post_name,
|
||||||
|
'date' => mysql_to_rfc3339($post->post_date),
|
||||||
|
'content' => $content,
|
||||||
|
'excerpt' => wp_trim_words(strip_tags($post->post_excerpt), 55),
|
||||||
|
'author' => array(
|
||||||
|
'id' => intval($post->post_author),
|
||||||
|
'name' => get_the_author_meta('display_name', $post->post_author),
|
||||||
|
),
|
||||||
|
'featured_image' => $featured_image_url,
|
||||||
|
'categories' => $categories,
|
||||||
|
'keywords' => $keywords,
|
||||||
|
'comments' => $comments,
|
||||||
|
'media_files' => $unique_media,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rest_ensure_response($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert Kategorien und Tags für einen bestimmten Beitrag.
|
||||||
|
*/
|
||||||
|
function rbr_get_post_taxonomies($data) {
|
||||||
|
$post_id = intval($data['id']);
|
||||||
|
$post = get_post($post_id);
|
||||||
|
|
||||||
|
if (!$post || $post->post_status !== 'publish') {
|
||||||
|
return new WP_Error('no_post', 'Beitrag nicht gefunden.', array('status' => 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
$taxonomies = get_object_taxonomies('post');
|
||||||
|
$terms = array();
|
||||||
|
|
||||||
|
foreach ($taxonomies as $taxonomy) {
|
||||||
|
$terms_list = get_the_terms($post_id, $taxonomy);
|
||||||
|
if (!empty($terms_list) && !is_wp_error($terms_list)) {
|
||||||
|
foreach ($terms_list as $term) {
|
||||||
|
$terms[] = array(
|
||||||
|
'id' => intval($term->term_id),
|
||||||
|
'name' => $term->name,
|
||||||
|
'slug' => $term->slug,
|
||||||
|
'type' => $taxonomy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response(array(
|
||||||
|
'post_id' => $post_id,
|
||||||
|
'taxonomies' => $terms,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle Medien-Dateien (Attachments) der WordPress-Instanz.
|
||||||
|
*/
|
||||||
|
function rbr_get_all_media($request) {
|
||||||
|
$per_page = rbr_sanitize_per_page($request['per_page']);
|
||||||
|
|
||||||
|
$args = array(
|
||||||
|
'post_type' => 'attachment',
|
||||||
|
'post_status' => 'inherit',
|
||||||
|
'numberposts' => $per_page,
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'ASC',
|
||||||
|
'post_mime_type' => array('image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$attachments = get_posts($args);
|
||||||
|
$media_list = array();
|
||||||
|
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
$media_list[] = array(
|
||||||
|
'id' => intval($att->ID),
|
||||||
|
'url' => wp_get_attachment_url($att->ID),
|
||||||
|
'title' => $att->post_title,
|
||||||
|
'alt' => get_post_meta($att->ID, '_wp_attachment_image_alt', true),
|
||||||
|
'mime' => $att->post_mime_type,
|
||||||
|
'date' => mysql_to_rfc3339($att->post_date),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response(array(
|
||||||
|
'count' => count($media_list),
|
||||||
|
'media' => $media_list,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine einzelne Medien-Datei anhand ihrer ID.
|
||||||
|
*/
|
||||||
|
function rbr_get_media_by_id($data) {
|
||||||
|
$media_id = intval($data['id']);
|
||||||
|
$attachment = get_post($media_id);
|
||||||
|
|
||||||
|
if (!$attachment || $attachment->post_type !== 'attachment') {
|
||||||
|
return new WP_Error('no_media', 'Medien-Datei nicht gefunden.', array('status' => 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response(array(
|
||||||
|
'id' => intval($attachment->ID),
|
||||||
|
'url' => wp_get_attachment_url($attachment->ID),
|
||||||
|
'title' => $attachment->post_title,
|
||||||
|
'alt' => get_post_meta($attachment->ID, '_wp_attachment_image_alt', true),
|
||||||
|
'mime' => $attachment->post_mime_type,
|
||||||
|
'date' => mysql_to_rfc3339($attachment->post_date),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcode: [rbr_export] – Zeigt eine Export-Statistik im Frontend an.
|
||||||
|
*/
|
||||||
|
function rbr_export_shortcode($atts) {
|
||||||
|
$atts = shortcode_atts(array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
), $atts, 'rbr_export');
|
||||||
|
|
||||||
|
$count = wp_count_posts($atts['post_type']);
|
||||||
|
$published = $count->publish ?? 0;
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<div class="rbr-export-info"><p>Es gibt <strong>%d</strong> veröffentlichte %s-Beiträge, die über die REST-API exportiert werden können.</p></div>',
|
||||||
|
intval($published),
|
||||||
|
esc_html($atts['post_type'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
add_shortcode('rbr_export', 'rbr_export_shortcode');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Notiz: Plugin ist aktiv und bereit für den Export.
|
||||||
|
*/
|
||||||
|
add_action('admin_notices', function () {
|
||||||
|
if (current_user_can('manage_options')) {
|
||||||
|
echo '<div class="notice notice-info is-dismissible"><p>';
|
||||||
|
printf(
|
||||||
|
__('RBR Export to Ghost ist aktiv. <a href="%s">REST-API-Endpunkte</a> stehen unter <code>/wp-json/custom/v1/</code> zur Verfügung.', 'rbr-export-ghost'),
|
||||||
|
admin_url('tools.php')
|
||||||
|
);
|
||||||
|
echo '</p></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS-Header erlauben (falls nötig für den C#-Importer)
|
||||||
|
*/
|
||||||
|
add_filter('rest_pre_serve_request', function ($served, $result, $request) {
|
||||||
|
if (strpos($request->get_route(), 'custom/v1/') === 0) {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||||
|
}
|
||||||
|
return $served;
|
||||||
|
}, 10, 3);
|
||||||
|
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
// OPTIONS-Requests für CORS erlauben
|
||||||
|
register_rest_route('custom/v1', '/cors', array(
|
||||||
|
'methods' => 'OPTIONS',
|
||||||
|
'callback' => function () {
|
||||||
|
return new WP_REST_Response(null, 200);
|
||||||
|
},
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
));
|
||||||
|
});
|
||||||
BIN
WPPlugin/rbrExportRestApi.zip
Normal file
BIN
WPPlugin/rbrExportRestApi.zip
Normal file
Binary file not shown.
Loading…
Reference in a new issue