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;
}
}
}