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

410 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
// =============================================================================
/// <summary>Ein Post in Ghost zusammen mit dem, was lokal über ihn bekannt ist.</summary>
public class PostCheckContext
{
public GhostAdminPost Ghost { get; init; } = new();
public GhostPostFile Local { get; init; } = new();
}
/// <summary>Eine gefundene Abweichung und wie sie behoben wird.</summary>
public class PostFix
{
/// <summary>Für die Anzeige, z. B. "Datum 10.10.2026 14:03 → 19.11.2022 21:17".</summary>
public string Description { get; init; } = "";
/// <summary>Trägt die Korrektur in die Änderung ein, die an Ghost gesendet wird.</summary>
public Action<GhostAdminPostEdit> Apply { get; init; } = _ => { };
}
public interface IPostCheck
{
string Name { get; }
/// <summary>Liefert null, wenn der Post in Ordnung ist, sonst die nötige Korrektur.</summary>
PostFix? Check(PostCheckContext context);
}
/// <summary>Das Veröffentlichungsdatum in Ghost muss dem des WordPress-Beitrags entsprechen.</summary>
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<string, int> ByGhostSlug = new(StringComparer.OrdinalIgnoreCase);
private static string FilePath = "";
/// <summary>Lädt die Zuordnungen einer Ghost-Instanz (jede Instanz hat ihre eigene Datei).</summary>
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;
/// <summary>Der Slug, unter dem dieser WordPress-Beitrag in Ghost liegt — falls er zugeordnet wurde.</summary>
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<IPostCheck> 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<string, GhostPostFile>(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<PostCheckContext>();
var unmatched = new List<GhostAdminPost>();
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<PostFix> 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}");
}
}
/// <summary>
/// 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.
/// </summary>
private static List<PostCheckContext> AskForAssignments(List<GhostAdminPost> unmatched, Dictionary<int, GhostPostFile> locals, HashSet<int> taken, ref int ignored)
{
var assigned = new List<PostCheckContext>();
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<ID> 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<ID> / i = ignorieren / s = später / x = Rest später]: "
: " Zuordnen? [Nummer / w<ID> / 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<int, GhostPostFile> LoadLocalPosts(string baseDir)
{
var byId = new Dictionary<int, GhostPostFile>();
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<GhostPostFile>(File.ReadAllText(file));
if (post != null && post.WpId > 0) byId[post.WpId] = post;
}
catch { }
}
}
return byId;
}
}
}