2026-09-20 08:31:23 +02:00
|
|
|
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)
|
|
|
|
|
{
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image,codeinjection_head";
|
2026-09-20 08:31:23 +02:00
|
|
|
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)
|
|
|
|
|
{
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
// Browse-Endpunkte sind paginiert -> alle Seiten laden
|
|
|
|
|
var all = new List<GhostAdminTag>();
|
|
|
|
|
for (int page = 1; ; page++)
|
|
|
|
|
{
|
|
|
|
|
string endpoint = $"tags/?limit={limit}&page={page}&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) break;
|
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
|
|
|
var tags = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json)?.Tags ?? new();
|
|
|
|
|
all.AddRange(tags);
|
|
|
|
|
if (tags.Count < limit) break;
|
|
|
|
|
}
|
|
|
|
|
return all;
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
var existingByName = new Dictionary<string, GhostAdminTag>();
|
|
|
|
|
foreach (var t in existingTags.Where(t => t.Name != null))
|
|
|
|
|
existingByName.TryAdd(t.Name!.ToLowerInvariant(), t);
|
2026-09-20 08:31:23 +02:00
|
|
|
|
|
|
|
|
foreach (var cat in categories)
|
|
|
|
|
{
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
// WordPress liefert Namen HTML-kodiert (z.B. "Tipps & Tricks")
|
|
|
|
|
string name = System.Net.WebUtility.HtmlDecode(cat.Name ?? "").Trim();
|
2026-09-20 08:31:23 +02:00
|
|
|
if (string.IsNullOrEmpty(name)) continue;
|
|
|
|
|
if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing))
|
|
|
|
|
{
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
// Reihenfolge bleibt erhalten: der erste Tag ist in Ghost der primäre
|
|
|
|
|
if (!result.Contains(existing)) result.Add(existing);
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
Console.Write($" [Tag] Erstelle '{name}'...");
|
|
|
|
|
var newTag = await CreateTagAsync(name, cat.Slug);
|
|
|
|
|
if (newTag != null)
|
|
|
|
|
{
|
|
|
|
|
Console.WriteLine(" OK");
|
|
|
|
|
result.Add(newTag);
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
existingByName[name.ToLowerInvariant()] = newTag;
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
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) =====================
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
private static readonly System.Text.RegularExpressions.Regex MetaKeywordsRegex = new(
|
|
|
|
|
@"<meta\s+name\s*=\s*[""']keywords[""'][^>]*>\s*",
|
|
|
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Ghost kennt kein meta_keywords-Feld -> Keywords als <meta name="keywords"> in den
|
|
|
|
|
/// Code-Injection-Header des Posts schreiben. Vorhandener Header-Code bleibt erhalten,
|
|
|
|
|
/// ein bereits vorhandenes Keywords-Meta wird ersetzt.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static string? BuildKeywordsHead(string? existingHead, IEnumerable<string> keywords)
|
|
|
|
|
{
|
|
|
|
|
var list = keywords
|
|
|
|
|
.Select(k => System.Net.WebUtility.HtmlDecode(k ?? "").Trim())
|
|
|
|
|
.Where(k => k.Length > 0)
|
|
|
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToList();
|
|
|
|
|
if (list.Count == 0) return null;
|
|
|
|
|
|
|
|
|
|
string rest = MetaKeywordsRegex.Replace(existingHead ?? "", "").Trim();
|
|
|
|
|
string meta = $"<meta name=\"keywords\" content=\"{System.Net.WebUtility.HtmlEncode(string.Join(", ", list))}\">";
|
|
|
|
|
return rest.Length > 0 ? rest + "\n" + meta : meta;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
|
|
|
|
var post = new GhostAdminPostEdit
|
|
|
|
|
{
|
|
|
|
|
Title = title,
|
|
|
|
|
Html = html,
|
|
|
|
|
Status = status,
|
|
|
|
|
FeatureImage = featureImage,
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
CodeinjectionHead = codeinjectionHead
|
2026-09-20 08:31:23 +02:00
|
|
|
};
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null)
|
2026-09-20 08:31:23 +02:00
|
|
|
{
|
|
|
|
|
var post = new GhostAdminPostEdit
|
|
|
|
|
{
|
|
|
|
|
Id = postId,
|
|
|
|
|
Title = title,
|
|
|
|
|
Html = html,
|
|
|
|
|
Status = status,
|
|
|
|
|
UpdatedAt = updatedAt,
|
|
|
|
|
FeatureImage = featureImage,
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
CodeinjectionHead = codeinjectionHead
|
2026-09-20 08:31:23 +02:00
|
|
|
};
|
|
|
|
|
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; }
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
|
|
|
|
|
[JsonPropertyName("codeinjection_head")]
|
|
|
|
|
public string? CodeinjectionHead { get; set; }
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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; }
|
|
|
|
|
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
[JsonPropertyName("codeinjection_head")]
|
2026-09-20 08:31:23 +02:00
|
|
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost
Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert
Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px
Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:18 +02:00
|
|
|
public string? CodeinjectionHead { get; set; }
|
2026-09-20 08:31:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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; }
|
|
|
|
|
}
|
|
|
|
|
}
|