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

882 lines
38 KiB
C#

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,codeinjection_head";
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, string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
Title = title,
Html = html,
Status = status,
PublishedAt = publishedAt,
CreatedAt = publishedAt
};
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", string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
Id = postId,
Title = title,
Html = html,
Status = status,
UpdatedAt = updatedAt,
PublishedAt = publishedAt
};
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 =====================
/// <summary>Alle Tags mit der Anzahl ihrer Posts.</summary>
public static async Task<List<GhostAdminTag>> BrowseTagsWithCountsAsync()
{
const int limit = 100;
var all = new List<GhostAdminTag>();
for (int page = 1; ; page++)
{
var request = await CreateRequestAsync(HttpMethod.Get, $"tags/?limit={limit}&page={page}&include=count.posts&order=name%20asc");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var tags = JsonSerializer.Deserialize<GhostAdminTagsResponse>(await response.Content.ReadAsStringAsync())?.Tags ?? new();
all.AddRange(tags);
if (tags.Count < limit) break;
}
return all;
}
/// <summary>Alle Posts in Ghost (jeden Status), nur mit den Kopfdaten — ohne Inhalt.</summary>
public static async Task<List<GhostAdminPost>> BrowseAllPostsAsync()
{
const int limit = 100;
var all = new List<GhostAdminPost>();
for (int page = 1; ; page++)
{
var request = await CreateRequestAsync(HttpMethod.Get, $"posts/?limit={limit}&page={page}&fields=id,title,slug,status,created_at,updated_at,published_at,feature_image&order=published_at%20desc");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var posts = JsonSerializer.Deserialize<GhostAdminPostsResponse>(await response.Content.ReadAsStringAsync())?.Posts ?? new();
all.AddRange(posts);
if (posts.Count < limit) break;
}
return all;
}
/// <summary>
/// Ändert einzelne Felder eines Posts. Gesendet wird nur, was in "edit" gesetzt ist
/// (plus updated_at als Schutz vor gleichzeitigen Änderungen); alles andere bleibt unberührt.
/// </summary>
public static async Task<bool> UpdatePostFieldsAsync(string postId, GhostAdminPostEdit edit)
{
var body = new { posts = new[] { edit } };
var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/");
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 false;
}
return true;
}
/// <summary>Alle Posts (jeden Status) mit einem bestimmten Tag, jeweils mit ihrer vollständigen Tag-Liste.</summary>
public static async Task<List<GhostAdminPost>> BrowsePostsByTagAsync(string tagSlug)
{
const int limit = 50;
var all = new List<GhostAdminPost>();
for (int page = 1; ; page++)
{
string filter = Uri.EscapeDataString($"tag:{tagSlug}");
var request = await CreateRequestAsync(HttpMethod.Get, $"posts/?limit={limit}&page={page}&filter={filter}&include=tags&order=published_at%20asc");
var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var posts = JsonSerializer.Deserialize<GhostAdminPostsResponse>(await response.Content.ReadAsStringAsync())?.Posts ?? new();
all.AddRange(posts);
if (posts.Count < limit) break;
}
return all;
}
/// <summary>
/// Setzt die Tags eines Posts. Ghost ersetzt die Zuordnung komplett, daher muss die
/// vollständige Liste in der gewünschten Reihenfolge übergeben werden. Inhalt, Titel usw.
/// bleiben unverändert.
/// </summary>
public static async Task<bool> SetPostTagsAsync(string postId, string updatedAt, IEnumerable<string> tagIds)
{
var post = new GhostAdminPostEdit
{
UpdatedAt = updatedAt,
Tags = tagIds.Select(id => new GhostAdminTagRef { Id = id }).ToList()
};
var body = new { posts = new[] { post } };
var request = await CreateRequestAsync(HttpMethod.Put, $"posts/{postId}/");
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 false;
}
return true;
}
public static async Task<List<GhostAdminTag>> BrowseTagsAsync(string filter = "", int limit = 100)
{
// 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;
}
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 = new Dictionary<string, GhostAdminTag>();
foreach (var t in existingTags.Where(t => t.Name != null))
existingByName.TryAdd(t.Name!.ToLowerInvariant(), t);
foreach (var cat in categories)
{
// WordPress liefert Namen HTML-kodiert (z.B. "Tipps &amp; Tricks")
string name = System.Net.WebUtility.HtmlDecode(cat.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) continue;
if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing))
{
// Reihenfolge bleibt erhalten: der erste Tag ist in Ghost der primäre
if (!result.Contains(existing)) result.Add(existing);
}
else
{
Console.Write($" [Tag] Erstelle '{name}'...");
// Leerer Fallback → Ghost bildet den Slug selbst aus dem Namen
string tagSlug = SlugHelper.Clean(cat.Slug, "");
var newTag = await CreateTagAsync(name, tagSlug.Length > 0 ? tagSlug : null);
if (newTag != null)
{
Console.WriteLine(" OK");
result.Add(newTag);
existingByName[name.ToLowerInvariant()] = 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) =====================
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 &lt;meta name="keywords"&gt; 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, string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
Title = title,
Html = html,
Status = status,
FeatureImage = featureImage,
CodeinjectionHead = codeinjectionHead,
PublishedAt = publishedAt,
CreatedAt = publishedAt
};
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? codeinjectionHead = null, string? publishedAt = null)
{
var post = new GhostAdminPostEdit
{
Id = postId,
Title = title,
Html = html,
Status = status,
UpdatedAt = updatedAt,
FeatureImage = featureImage,
CodeinjectionHead = codeinjectionHead,
PublishedAt = publishedAt
};
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; }
}
// Video-Uploads dauern länger als der Standard-Timeout von 100 Sekunden
private static readonly HttpClient mediaUploadClient = new() { Timeout = TimeSpan.FromMinutes(60) };
/// <summary>Lädt ein Video über die Media-API hoch und liefert seine Adresse in Ghost.</summary>
public static async Task<string?> UploadMediaAsync(string localPath)
{
if (!File.Exists(localPath))
{
Console.WriteLine($" [Ghost-Fehler] Datei nicht gefunden: {localPath}");
return null;
}
string fileName = Path.GetFileName(localPath);
string mimeType = Path.GetExtension(fileName).ToLowerInvariant() switch
{
".mp4" or ".m4v" => "video/mp4",
".webm" => "video/webm",
".ogv" => "video/ogg",
".mov" => "video/quicktime",
_ => "application/octet-stream"
};
try
{
var request = await CreateRequestAsync(HttpMethod.Post, "media/upload/");
await using var fileStream = File.OpenRead(localPath);
var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
var formContent = new MultipartFormDataContent();
formContent.Add(fileContent, "file", fileName);
request.Content = formContent;
var response = await mediaUploadClient.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
string hint = response.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge
? " (Datei größer als das Upload-Limit des Ghost-Servers)" : "";
Console.WriteLine($" [Ghost-Fehler] Video-Upload fehlgeschlagen: {response.StatusCode}{hint}: {body}");
return null;
}
using var json = JsonDocument.Parse(body);
return json.RootElement.GetProperty("media")[0].GetProperty("url").GetString();
}
catch (Exception ex)
{
Console.WriteLine($" [Ghost-Fehler] Video-Upload fehlgeschlagen: {ex.Message}");
return null;
}
}
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 ?? "", publishedAt: DateHelper.ToGhostUtc(localPost.PublishedAt));
if (updatedPost != null)
{
Console.WriteLine(" OK");
updated++;
}
else
Console.WriteLine(" FEHLER");
}
else
{
Console.Write($" [Neu] {title}...");
var newPost = await CreatePostAsync(title, html, "published", slug, DateHelper.ToGhostUtc(localPost.PublishedAt));
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; }
[JsonPropertyName("codeinjection_head")]
public string? CodeinjectionHead { get; set; }
[JsonPropertyName("published_at")]
public string? PublishedAt { get; set; }
// Nur gefüllt, wenn mit include=tags abgefragt
[JsonPropertyName("tags")]
public List<GhostAdminTag>? Tags { 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; }
// Veröffentlichungs-/Erstelldatum (UTC). Ohne Angabe setzt Ghost den Zeitpunkt des Imports.
[JsonPropertyName("published_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PublishedAt { get; set; }
[JsonPropertyName("created_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CreatedAt { 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("codeinjection_head")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CodeinjectionHead { 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; }
// Nur gefüllt, wenn mit include=count.posts abgefragt
[JsonPropertyName("count")]
public GhostAdminTagCount? Count { get; set; }
}
public class GhostAdminTagCount
{
[JsonPropertyName("posts")]
public int Posts { 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; }
}
}