using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; namespace wp2ghost { // ============================================================================= // Prüffunktion für bereits nach Ghost gesendete Posts. // // Jede Prüfung (IPostCheck) vergleicht einen Post in Ghost mit seiner lokalen JSON-Datei // und liefert bei einer Abweichung eine Korrektur (PostFix). Alle Korrekturen eines Posts // werden zusammen in EINER Änderung an Ghost gesendet. // // Neue Prüfung hinzufügen: Klasse mit IPostCheck schreiben und in PostChecker.Checks eintragen. // ============================================================================= /// Ein Post in Ghost zusammen mit dem, was lokal über ihn bekannt ist. public class PostCheckContext { public GhostAdminPost Ghost { get; init; } = new(); public GhostPostFile Local { get; init; } = new(); } /// Eine gefundene Abweichung und wie sie behoben wird. public class PostFix { /// Für die Anzeige, z. B. "Datum 10.10.2026 14:03 → 19.11.2022 21:17". public string Description { get; init; } = ""; /// Trägt die Korrektur in die Änderung ein, die an Ghost gesendet wird. public Action Apply { get; init; } = _ => { }; } public interface IPostCheck { string Name { get; } /// Liefert null, wenn der Post in Ordnung ist, sonst die nötige Korrektur. PostFix? Check(PostCheckContext context); } /// Das Veröffentlichungsdatum in Ghost muss dem des WordPress-Beitrags entsprechen. public class PublishDateCheck : IPostCheck { public string Name => "Postdatum auf das WordPress-Datum korrigieren"; public PostFix? Check(PostCheckContext context) { // Entwürfe und geplante Posts haben (noch) kein gültiges Veröffentlichungsdatum if (context.Ghost.Status != "published") return null; string? expected = DateHelper.ToGhostUtc(context.Local.PublishedAt ?? context.Local.CreatedAt); if (expected == null) return null; var expectedDate = DateTimeOffset.Parse(expected, CultureInfo.InvariantCulture); bool hasActual = DateTimeOffset.TryParse(context.Ghost.PublishedAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var actualDate); // Ghost speichert auf die Sekunde genau if (hasActual && Math.Abs((actualDate - expectedDate).TotalSeconds) < 60) return null; string actualText = hasActual ? actualDate.ToLocalTime().ToString("dd.MM.yyyy HH:mm") : "(keines)"; return new PostFix { Description = $"Datum {actualText} → {expectedDate.ToLocalTime():dd.MM.yyyy HH:mm}", Apply = edit => edit.PublishedAt = expected }; } } // ============================================================================= // SlugAssignments — vom User getroffene Zuordnungen "Slug in Ghost → WordPress-Beitrag". // Nötig, wenn der Slug in Ghost nicht dem entspricht, den der Importer bilden würde // (z. B. wegen fehlerhafter Zeichen, einer Kollision oder weil er in Ghost geändert wurde). // ============================================================================= public static class SlugAssignments { // Ghost-Slug → WordPress-ID; 0 = dieser Ghost-Post soll ignoriert werden private static readonly Dictionary ByGhostSlug = new(StringComparer.OrdinalIgnoreCase); private static string FilePath = ""; /// Lädt die Zuordnungen einer Ghost-Instanz (jede Instanz hat ihre eigene Datei). public static void Init(string filePath) { FilePath = filePath; ByGhostSlug.Clear(); if (!File.Exists(FilePath)) return; foreach (string line in File.ReadAllLines(FilePath)) { var parts = line.Split('|'); if (parts.Length == 2 && parts[0].Length > 0) ByGhostSlug[parts[0]] = parts[1] == "ignorieren" ? 0 : int.TryParse(parts[1], out int id) ? id : 0; } } public static bool IsIgnored(string ghostSlug) => ByGhostSlug.TryGetValue(ghostSlug, out int id) && id == 0; public static int? GetWpId(string ghostSlug) => ByGhostSlug.TryGetValue(ghostSlug, out int id) && id > 0 ? id : null; /// Der Slug, unter dem dieser WordPress-Beitrag in Ghost liegt — falls er zugeordnet wurde. public static string? GetGhostSlug(int wpId) => wpId > 0 ? ByGhostSlug.FirstOrDefault(kv => kv.Value == wpId).Key : null; public static void Assign(string ghostSlug, int wpId) { ByGhostSlug[ghostSlug] = wpId; Save(); } public static void Ignore(string ghostSlug) { ByGhostSlug[ghostSlug] = 0; Save(); } private static void Save() { if (FilePath.Length == 0) return; File.WriteAllLines(FilePath, ByGhostSlug.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}|{(kv.Value == 0 ? "ignorieren" : kv.Value.ToString())}")); } } public static class PostChecker { // Alle verfügbaren Prüfungen — hier werden neue eingetragen private static readonly List Checks = new() { new PublishDateCheck() }; public static async Task RunAsync(string baseDir) { // 1. Welche Prüfungen? var selected = Checks; if (Checks.Count > 1) { Console.WriteLine(" Verfügbare Prüfungen:"); for (int i = 0; i < Checks.Count; i++) Console.WriteLine($" [{i + 1}] {Checks[i].Name}"); Console.Write(" Welche ausführen? (Nummern mit Komma, leer = alle): "); string input = Console.ReadLine()?.Trim() ?? ""; if (input.Length > 0) { selected = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => int.TryParse(s, out int n) && n >= 1 && n <= Checks.Count ? Checks[n - 1] : null) .Where(c => c != null).Select(c => c!).Distinct().ToList(); if (selected.Count == 0) { Console.WriteLine(" Keine gültige Auswahl."); return; } } } else { Console.WriteLine($" Prüfung: {Checks[0].Name}"); } try { // 2. Lokale JSON-Dateien (auch archivierte) bereitlegen var locals = LoadLocalPosts(baseDir); if (locals.Count == 0) { Console.WriteLine(" Keine lokalen JSON-Dateien in ghostPosts/ (oder ghostPosts/archiv/) gefunden."); return; } // Derselbe bereinigte Slug, unter dem der Import den Post in Ghost anlegt var localBySlug = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var local in locals.Values) localBySlug[SlugHelper.Clean(local.Slug, $"post-{local.WpId}")] = local; // 3. Posts aus Ghost holen und den lokalen Dateien zuordnen Console.Write(" Lade Posts aus Ghost..."); var ghostPosts = await GhostApi.BrowseAllPostsAsync(); Console.WriteLine($" {ghostPosts.Count}"); var pairs = new List(); var unmatched = new List(); int ignored = 0; foreach (var ghostPost in ghostPosts) { string slug = ghostPost.Slug ?? ""; if (SlugAssignments.IsIgnored(slug)) { ignored++; continue; } // Eine Zuordnung des Users hat Vorrang vor dem Slug-Vergleich GhostPostFile? local = null; if (SlugAssignments.GetWpId(slug) is int assignedId) locals.TryGetValue(assignedId, out local); if (local == null) localBySlug.TryGetValue(slug, out local); if (local != null) pairs.Add(new PostCheckContext { Ghost = ghostPost, Local = local }); else unmatched.Add(ghostPost); } // 4. Für Posts ohne Zuordnung den User fragen if (unmatched.Count > 0) { var taken = pairs.Select(p => p.Local.WpId).ToHashSet(); pairs.AddRange(AskForAssignments(unmatched, locals, taken, ref ignored)); } Console.WriteLine($" {pairs.Count} von {ghostPosts.Count} Posts in Ghost sind einem WordPress-Beitrag zugeordnet" + $"{(ignored > 0 ? $", {ignored} ignoriert" : "")}" + $"{(ghostPosts.Count - pairs.Count - ignored > 0 ? $", {ghostPosts.Count - pairs.Count - ignored} ohne Zuordnung (nicht geprüft)" : "")}."); // 5. Prüfen var findings = new List<(GhostAdminPost Post, List Fixes)>(); foreach (var context in pairs) { var fixes = selected.Select(c => c.Check(context)).Where(f => f != null).Select(f => f!).ToList(); if (fixes.Count > 0) findings.Add((context.Ghost, fixes)); } if (findings.Count == 0) { Console.WriteLine(" Alles in Ordnung – nichts zu korrigieren."); return; } Console.WriteLine($" {findings.Count} Post(s) mit Abweichungen:"); foreach (var (post, fixes) in findings) { Console.WriteLine($" {post.Title}"); foreach (var fix in fixes) Console.WriteLine($" {fix.Description}"); } // 6. Korrigieren Console.Write(" Korrigieren? (a = alle / e = einzeln fragen / n = nichts): "); string mode = Console.ReadLine()?.Trim().ToLowerInvariant() ?? ""; if (mode != "a" && mode != "e") return; int done = 0, skipped = 0; foreach (var (post, fixes) in findings) { if (mode == "e") { Console.Write($" {post.Title}: {string.Join("; ", fixes.Select(f => f.Description))} – korrigieren? (j/n): "); if (Console.ReadLine()?.Trim().ToLowerInvariant() != "j") { skipped++; continue; } } else { Console.Write($" [{done + 1}/{findings.Count}] {post.Title}..."); } // Alle Korrekturen dieses Posts in einer Änderung var edit = new GhostAdminPostEdit { UpdatedAt = post.UpdatedAt }; foreach (var fix in fixes) fix.Apply(edit); bool ok = await GhostApi.UpdatePostFieldsAsync(post.Id!, edit); Console.WriteLine(ok ? " OK" : " FEHLER"); if (!ok) { Console.WriteLine($" ABBRUCH wegen eines Fehlers. {done} Post(s) korrigiert."); return; } done++; } Console.WriteLine($" {done} Post(s) korrigiert{(skipped > 0 ? $", {skipped} übersprungen" : "")}."); } catch (OperationCanceledException) { throw; // Abbruch durch den User (Web-Oberfläche) nicht als Fehler melden } catch (Exception ex) { Console.WriteLine($" [Fehler] {ex.Message}"); } } /// /// Fragt für jeden Ghost-Post ohne Zuordnung, zu welchem WordPress-Beitrag er gehört. /// Der User kann einen Vorschlag wählen, eine WordPress-ID angeben, den Slug dauerhaft /// ignorieren oder die Frage auf später verschieben. Zuordnungen und "ignorieren" werden gemerkt. /// private static List AskForAssignments(List unmatched, Dictionary locals, HashSet taken, ref int ignored) { var assigned = new List(); Console.WriteLine(); Console.WriteLine($" {unmatched.Count} Post(s) in Ghost lassen sich über den Slug keinem WordPress-Beitrag zuordnen."); Console.WriteLine(" Eingabe je Post: Nummer eines Vorschlags, w für eine WordPress-ID (z. B. w861),"); Console.WriteLine(" i = dauerhaft ignorieren, Enter = später, x = alle restlichen später"); for (int index = 0; index < unmatched.Count; index++) { var ghostPost = unmatched[index]; string slug = ghostPost.Slug ?? ""; // Vorschläge: noch nicht zugeordnete WordPress-Beiträge, ähnlichste zuerst var suggestions = locals.Values .Where(l => !taken.Contains(l.WpId)) .Select(l => (Local: l, Score: Similarity(ghostPost, l))) .Where(s => s.Score >= 0.34) .OrderByDescending(s => s.Score) .Take(5) .ToList(); bool exactTitle = suggestions.Count > 0 && suggestions[0].Score >= 1.0 && (suggestions.Count == 1 || suggestions[1].Score < 1.0); Console.WriteLine(); Console.WriteLine($" [{index + 1}/{unmatched.Count}] Ghost: \"{ghostPost.Title}\""); Console.WriteLine($" Slug: {slug}"); if (suggestions.Count == 0) Console.WriteLine(" Kein ähnlicher WordPress-Beitrag gefunden."); for (int i = 0; i < suggestions.Count; i++) Console.WriteLine($" [{i + 1}] WP {suggestions[i].Local.WpId}: \"{System.Net.WebUtility.HtmlDecode(suggestions[i].Local.Title ?? "")}\" ({Uri.UnescapeDataString(suggestions[i].Local.WpSlug ?? "")})"); GhostPostFile? chosen = null; bool decided = false, stop = false; while (!decided) { Console.Write(exactTitle ? " Zuordnen? [Enter = Vorschlag 1 (gleicher Titel) / Nummer / w / i = ignorieren / s = später / x = Rest später]: " : " Zuordnen? [Nummer / w / i = ignorieren / Enter = später / x = Rest später]: "); string? raw = Console.ReadLine(); if (raw == null) { stop = true; break; } string input = raw.Trim().ToLowerInvariant(); if (input == "x") { stop = true; break; } if (input == "i") { SlugAssignments.Ignore(slug); ignored++; Console.WriteLine(" -> wird dauerhaft ignoriert"); decided = true; } else if (input == "s" || (input == "" && !exactTitle)) { decided = true; // später erneut fragen } else if (input == "" && exactTitle) { chosen = suggestions[0].Local; } else if (int.TryParse(input, out int number) && number >= 1 && number <= suggestions.Count) { chosen = suggestions[number - 1].Local; } else if (input.StartsWith("w") && int.TryParse(input.Substring(1), out int wpId)) { if (!locals.TryGetValue(wpId, out chosen)) Console.WriteLine($" Für WordPress-ID {wpId} gibt es keine lokale JSON-Datei."); else if (taken.Contains(wpId)) { Console.WriteLine($" WordPress-Beitrag {wpId} ist bereits einem anderen Ghost-Post zugeordnet."); chosen = null; } } else { Console.WriteLine(" Ungültige Eingabe."); } if (chosen != null) { SlugAssignments.Assign(slug, chosen.WpId); taken.Add(chosen.WpId); assigned.Add(new PostCheckContext { Ghost = ghostPost, Local = chosen }); Console.WriteLine($" -> zugeordnet zu WP {chosen.WpId}: \"{System.Net.WebUtility.HtmlDecode(chosen.Title ?? "")}\""); decided = true; } } if (stop) break; } Console.WriteLine(); return assigned; } // Ähnlichkeit 0..1: gleicher Titel = 1, sonst gemeinsame Wörter im Titel bzw. gemeinsamer Slug-Anfang private static double Similarity(GhostAdminPost ghost, GhostPostFile local) { string ghostTitle = Normalize(ghost.Title), localTitle = Normalize(local.Title); if (ghostTitle.Length > 0 && ghostTitle == localTitle) return 1.0; var a = ghostTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); var b = localTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); double titleScore = a.Count == 0 || b.Count == 0 ? 0 : (double)a.Intersect(b).Count() / a.Union(b).Count(); string ghostSlug = ghost.Slug ?? "", localSlug = SlugHelper.Clean(local.Slug, ""); int common = 0; while (common < ghostSlug.Length && common < localSlug.Length && ghostSlug[common] == localSlug[common]) common++; double slugScore = ghostSlug.Length == 0 || localSlug.Length == 0 ? 0 : (double)common / Math.Max(ghostSlug.Length, localSlug.Length); // Knapp unter 1, damit nur ein wirklich gleicher Titel als "gleich" gilt return Math.Min(0.99, Math.Max(titleScore, slugScore)); } private static string Normalize(string? text) => Regex.Replace(System.Net.WebUtility.HtmlDecode(text ?? "").ToLowerInvariant(), @"[^\p{L}\p{Nd}]+", " ").Trim(); // Alle lokalen JSON-Dateien nach WordPress-ID private static Dictionary LoadLocalPosts(string baseDir) { var byId = new Dictionary(); string postsDir = Path.Combine(baseDir, "ghostPosts"); // Archiv zuerst: gibt es einen Post in beiden Ordnern, gilt die neuere Datei in ghostPosts/ foreach (string dir in new[] { Path.Combine(postsDir, "archiv"), postsDir }) { if (!Directory.Exists(dir)) continue; foreach (string file in Directory.GetFiles(dir, "*.json")) { try { var post = JsonSerializer.Deserialize(File.ReadAllText(file)); if (post != null && post.WpId > 0) byId[post.WpId] = post; } catch { } } } return byId; } } }