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 { { "alg", "HS256" }, { "typ", "JWT" }, { "kid", keyId } }; var payload = new Dictionary { { "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 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> 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(json); return result?.Posts ?? new List(); } public static async Task 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(json); return result?.Posts?.FirstOrDefault(); } public static async Task 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(json); return result?.Posts?.FirstOrDefault(); } public static async Task 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(json); return result?.Posts?.FirstOrDefault(); } public static async Task DeletePostAsync(string postId) { var request = await CreateRequestAsync(HttpMethod.Delete, $"posts/{postId}/"); var response = await httpClient.SendAsync(request); return response.IsSuccessStatusCode; } // ===================== Tags ===================== /// Alle Tags mit der Anzahl ihrer Posts. public static async Task> BrowseTagsWithCountsAsync() { const int limit = 100; var all = new List(); 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(await response.Content.ReadAsStringAsync())?.Tags ?? new(); all.AddRange(tags); if (tags.Count < limit) break; } return all; } /// Alle Posts in Ghost (jeden Status), nur mit den Kopfdaten — ohne Inhalt. public static async Task> BrowseAllPostsAsync() { const int limit = 100; var all = new List(); 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(await response.Content.ReadAsStringAsync())?.Posts ?? new(); all.AddRange(posts); if (posts.Count < limit) break; } return all; } /// /// Ä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. /// public static async Task 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; } /// Alle Posts (jeden Status) mit einem bestimmten Tag, jeweils mit ihrer vollständigen Tag-Liste. public static async Task> BrowsePostsByTagAsync(string tagSlug) { const int limit = 50; var all = new List(); 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(await response.Content.ReadAsStringAsync())?.Posts ?? new(); all.AddRange(posts); if (posts.Count < limit) break; } return all; } /// /// 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. /// public static async Task SetPostTagsAsync(string postId, string updatedAt, IEnumerable 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> BrowseTagsAsync(string filter = "", int limit = 100) { // Browse-Endpunkte sind paginiert -> alle Seiten laden var all = new List(); 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(json)?.Tags ?? new(); all.AddRange(tags); if (tags.Count < limit) break; } return all; } public static async Task 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(json); return result?.Tags?.FirstOrDefault(); } public static async Task> EnsureTagsAsync(List categories) { var result = new List(); var existingTags = await BrowseTagsAsync(); var existingByName = new Dictionary(); 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 & 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> 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(json); return result?.Members ?? new(); } public static async Task 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(json); return result?.Members?.FirstOrDefault(); } public static async Task 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( @"]*>\s*", System.Text.RegularExpressions.RegexOptions.IgnoreCase); /// /// 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. /// public static string? BuildKeywordsHead(string? existingHead, IEnumerable 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 = $""; return rest.Length > 0 ? rest + "\n" + meta : meta; } public static async Task CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List 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(json); return result?.Posts?.FirstOrDefault(); } public static async Task UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List 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(json); return result?.Posts?.FirstOrDefault(); } public static async Task 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(json); return result?.Images?.FirstOrDefault()?.Url; } public static async Task 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) }; /// Lädt ein Video über die Media-API hoch und liefert seine Adresse in Ghost. public static async Task 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 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 PushAllPostsAsync(Func 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? 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? 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? 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? 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? 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? 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; } } }