410 lines
20 KiB
C#
410 lines
20 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|