Compare commits

..

2 commits

Author SHA1 Message Date
6e558d90d0 - ghost admin 2026-09-24 19:23:54 +02:00
b598b0d5af Improve plugin and Fetcher 2026-09-22 08:08:51 +02:00
52 changed files with 3227 additions and 26 deletions

5
.gitignore vendored
View file

@ -1,3 +1,8 @@
/CSharpGhostImporter/.vs/* /CSharpGhostImporter/.vs/*
/CSharpGhostImporter/bin/* /CSharpGhostImporter/bin/*
/CSharpGhostImporter/obj/* /CSharpGhostImporter/obj/*
/0
/GhostImageManager/.vs/*
/GhostImageManager/bin/*
/GhostImageManager/obj/*
/GhostImageManager/Properties/*

View file

@ -1,5 +1,8 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Net;
using System.Net.Http;
using HtmlAgilityPack;
using wp2ghost; using wp2ghost;
class Program class Program
@ -22,6 +25,7 @@ class Program
ImageManager.Init(BaseDir); ImageManager.Init(BaseDir);
PostFetcher.Init(BaseDir); PostFetcher.Init(BaseDir);
GhostExporter.Init(BaseDir); GhostExporter.Init(BaseDir);
LinkDecisionManager.Init(BaseDir);
GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath); GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath);
Console.OutputEncoding = System.Text.Encoding.UTF8; Console.OutputEncoding = System.Text.Encoding.UTF8;
@ -57,8 +61,12 @@ class Program
case "7": case "7":
await GhostMenuAsync(); await GhostMenuAsync();
break; break;
case "8":
LinkDecisionManager.ShowInfo();
break;
case "0": case "0":
ImageManager.SaveCache(); ImageManager.SaveCache();
LinkDecisionManager.Save();
running = false; running = false;
Console.WriteLine("Programm beendet. Auf Wiedersehen!"); Console.WriteLine("Programm beendet. Auf Wiedersehen!");
break; break;
@ -89,6 +97,7 @@ class Program
Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen"); Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen");
Console.WriteLine(" 6 - Bilder lokal löschen"); Console.WriteLine(" 6 - Bilder lokal löschen");
Console.WriteLine(" 7 - Beiträge direkt an Ghost senden"); Console.WriteLine(" 7 - Beiträge direkt an Ghost senden");
Console.WriteLine(" 8 - Gespeicherte Link-Entscheidungen anzeigen");
Console.WriteLine(" 0 - Beenden"); Console.WriteLine(" 0 - Beenden");
Console.WriteLine("-----------------------------------------"); Console.WriteLine("-----------------------------------------");
Console.Write("Auswahl: "); Console.Write("Auswahl: ");
@ -352,6 +361,9 @@ class Program
Console.Write("HTML-Inhalt: "); Console.Write("HTML-Inhalt: ");
string? html = Console.ReadLine()?.Trim(); string? html = Console.ReadLine()?.Trim();
// Links validieren und ggf. korrigieren
html = await ValidateAndFixLinksAsync(html);
Console.Write("Status (published/draft) [published]: "); Console.Write("Status (published/draft) [published]: ");
string? status = Console.ReadLine()?.Trim(); string? status = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(status)) status = "published"; if (string.IsNullOrEmpty(status)) status = "published";
@ -459,6 +471,233 @@ class Program
Console.WriteLine($" Hochgeladen: {uploaded}/{cachedFiles.Count}"); Console.WriteLine($" Hochgeladen: {uploaded}/{cachedFiles.Count}");
} }
// =========================================================================
// HTML-Links validieren und bei Bedarf korrigieren
// =========================================================================
private static async Task<string> ValidateAndFixLinksAsync(string? html)
{
if (string.IsNullOrWhiteSpace(html)) return html ?? "";
var doc = new HtmlDocument();
doc.LoadHtml(html);
var invalidLinks = new List<(string text, string href, HtmlNode node)>();
// Alle <a>-Tags suchen
var anchors = doc.DocumentNode.SelectNodes("//a[@href]");
if (anchors == null) return html;
foreach (var anchor in anchors)
{
string href = anchor.GetAttributeValue("href", "").Trim();
string text = anchor.InnerText.Trim();
if (string.IsNullOrEmpty(href)) continue;
// Prüfe, ob URL valide ist (inkl. Reichbarkeitsprüfung)
if (!await IsValidUrlAsync(href, checkReachability: true))
{
invalidLinks.Add((text, href, anchor));
}
}
if (invalidLinks.Count == 0) return html;
// User über invaliden Links informieren und zur Korrektur auffordern
Console.WriteLine($"\n {invalidLinks.Count} ungültiger Link(s) gefunden:");
for (int i = 0; i < invalidLinks.Count; i++)
{
Console.WriteLine($" [{i + 1}] Text: \"{invalidLinks[i].text}\" | Link: {invalidLinks[i].href}");
}
Console.WriteLine("\nOptionen pro Link:");
Console.WriteLine(" d = Link löschen (Text bleibt, <a>-Tag entfernt)");
Console.WriteLine(" e = Link bearbeiten (neue URL eingeben)");
Console.WriteLine(" Enter = Link so übernehmen (beibehalten)");
// Für jeden invaliden Link die User-Entscheidung einholen
foreach (var (linkText, linkHref, anchorNode) in invalidLinks)
{
// 1. Memory: Frühere Entscheidung suchen und vorschlagen
var previous = LinkDecisionManager.GetPreviousDecision(linkText, linkHref);
Console.WriteLine($"\n ---------------------------------------------");
Console.WriteLine($" Link-Text : \"{linkText}\"");
Console.WriteLine($" Aktueller Link : {linkHref}");
if (previous != null)
{
string prevInfo = previous.Decision switch
{
"delete" => "LÖSCHEN",
"edit" => $"ERSETZEN durch '{previous.NewUrl}'",
_ => "BEIBEHALTEN"
};
Console.WriteLine($" [Memory] Frühere Entscheidung: {prevInfo} (vom {previous.Timestamp:dd.MM.yyyy HH:mm})");
}
// 2. Browser-Öffnen anbieten (da Link nicht erreichbar ist)
string searchQuery = Uri.EscapeDataString(linkText);
string searchUrl = $"https://www.google.com/search?q={searchQuery}";
Console.WriteLine($" [Suche] Fallstrick: Link nicht erreichbar. Browser-Suche verfügbar.");
Console.Write($" Optionen: [Enter=übernehmen" + (previous != null ? $" / v=Vorschlag '{previous.Decision}' anwenden" : "") + " / d=löschen / e=bearbeiten / b=Browser öffnen]: ");
string? choice = Console.ReadLine()?.Trim()?.ToLowerInvariant();
// Browser öffnen
if (choice == "b")
{
Console.WriteLine($" -> Öffne Browser mit Suche nach: \"{linkText}\"");
OpenBrowser(searchUrl);
// Nach dem Browser-Öffnen erneut nach der Entscheidung fragen
Console.Write($" Jetzt entscheiden? [Enter=übernehmen / d=löschen / e=bearbeiten]: ");
choice = Console.ReadLine()?.Trim()?.ToLowerInvariant();
}
// Memory-Vorschlag anwenden
if (choice == "v" && previous != null)
{
ApplyDecision(anchorNode, previous, linkText, linkHref, ref html, doc);
Console.WriteLine($" -> Frühere Entscheidung angewendet: {previous.Decision}");
continue;
}
if (choice == "d")
{
// Link löschen: <a>-Tag entfernen, Text erhalten
var parent = anchorNode.ParentNode;
if (parent != null)
{
parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode);
}
Console.WriteLine($" -> Link gelöscht");
LinkDecisionManager.SaveDecision(linkText, linkHref, "delete");
}
else if (choice == "e")
{
// Link bearbeiten: neue URL eingeben
Console.Write(" Neue URL eingeben: ");
string? newUrl = Console.ReadLine()?.Trim();
if (!string.IsNullOrEmpty(newUrl))
{
anchorNode.SetAttributeValue("href", newUrl);
Console.WriteLine($" -> Auf '{newUrl}' aktualisiert");
LinkDecisionManager.SaveDecision(linkText, linkHref, "edit", newUrl);
}
else
{
Console.WriteLine($" -> Beibehalten");
LinkDecisionManager.SaveDecision(linkText, linkHref, "keep");
}
}
else
{
Console.WriteLine($" -> Beibehalten");
LinkDecisionManager.SaveDecision(linkText, linkHref, "keep");
}
}
return doc.DocumentNode.OuterHtml;
}
// =========================================================================
// Hilfsmethoden für Link-Entscheidungen
// =========================================================================
/// <summary>
/// Wendet eine gespeicherte (frühere) Entscheidung auf den Link an.
/// </summary>
private static void ApplyDecision(HtmlNode anchorNode, LinkDecision decision, string linkText, string linkHref, ref string? html, HtmlDocument doc)
{
if (decision.Decision == "delete")
{
var parent = anchorNode.ParentNode;
if (parent != null)
{
parent.ReplaceChild(HtmlNode.CreateNode(anchorNode.InnerText), anchorNode);
}
}
else if (decision.Decision == "edit" && !string.IsNullOrEmpty(decision.NewUrl))
{
anchorNode.SetAttributeValue("href", decision.NewUrl);
}
// "keep" = nichts tun
}
/// <summary>
/// Öffnet die Standard-Browser-Anwendung mit der angegebenen URL.
/// </summary>
private static void OpenBrowser(string url)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
System.Diagnostics.Process.Start(psi);
}
catch (Exception ex)
{
Console.WriteLine($" [Browser-Fehler] Konnte Browser nicht öffnen: {ex.Message}");
}
}
private static async Task<bool> IsValidUrlAsync(string url, bool checkReachability = false)
{
if (string.IsNullOrWhiteSpace(url)) return false;
// Relative URLs (beginnen mit / oder sind Pfade) sind für interne Links gültig
if (url.StartsWith("/") || !url.Contains(":"))
{
if (checkReachability) { /* relative links are considered valid regardless */ }
return true;
}
// Prüfe auf gültiges URI-Format mit http/https Schemes
try
{
var uri = new Uri(url, UriKind.Absolute);
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
return false;
// Optionale Reichbarkeitsprüfung
if (checkReachability)
{
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
try
{
var request = new HttpRequestMessage(HttpMethod.Head, uri);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
return (int)response.StatusCode < 400; // 2xx oder 3xx = erreichbar
}
catch (HttpRequestException)
{
// Verbindungsfehler, DNS-Failure, Timeout etc.
return false;
}
catch (TaskCanceledException)
{
// Timeout
return false;
}
}
return true;
}
catch
{
return false;
}
}
// Synchronen Wrapper für die bestehende Validierung
private static bool IsValidUrl(string url)
{
return IsValidUrlAsync(url, false).Result;
}
static async Task GhostCreateOrUpdateFromJsonAsync() static async Task GhostCreateOrUpdateFromJsonAsync()
{ {
string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts"); string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts");
@ -509,12 +748,14 @@ class Program
Console.WriteLine($" Post: {postFile.Title}"); Console.WriteLine($" Post: {postFile.Title}");
Console.WriteLine($" Slug: {postFile.Slug}"); Console.WriteLine($" Slug: {postFile.Slug}");
Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); Console.WriteLine($" Kategorien: {postFile.Categories.Count}");
Console.WriteLine($" Tags: {postFile.Tags.Count}");
Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); Console.WriteLine($" Kommentare: {postFile.Comments.Count}");
Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}");
// 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen // 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen
var ghostUrlMap = new Dictionary<string, string>(); var ghostUrlMap = new Dictionary<string, string>();
string html = postFile.Html ?? ""; string html = postFile.Html ?? "";
html = await ValidateAndFixLinksAsync(html);
if (postFile.ImageFiles.Count > 0) if (postFile.ImageFiles.Count > 0)
{ {
@ -572,12 +813,47 @@ class Program
Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML"); Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML");
} }
// 4. Tags erstellen/zuordnen // 4. Tags erstellen/zuordnen (Kategorien + Tags)
var tags = new List<GhostAdminTag>(); var tags = new List<GhostAdminTag>();
if (postFile.Categories.Count > 0) var allTagSources = new List<WpCategory>();
// Bei mehreren Kategorien: primäre Kategorie abfragen
if (postFile.Categories.Count > 1)
{ {
Console.WriteLine(" [Tags] Verarbeite Kategorien..."); Console.WriteLine();
tags = await GhostApi.EnsureTagsAsync(postFile.Categories); Console.WriteLine(" [Kategorien] Mehrere Kategorien gefunden:");
for (int i = 0; i < postFile.Categories.Count; i++)
{
Console.WriteLine($" [{i + 1}] {postFile.Categories[i].Name}");
}
Console.Write(" Relevante Kategorie (Nummer, Enter = erste): ");
string? catChoice = Console.ReadLine()?.Trim();
if (int.TryParse(catChoice, out int catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count)
{
// Gewählte Kategorie an erste Stelle setzen
var primary = postFile.Categories[catIdx - 1];
allTagSources.Add(primary);
allTagSources.AddRange(postFile.Categories.Where((_, idx) => idx != catIdx - 1));
Console.WriteLine($" -> Primäre Kategorie: {primary.Name}");
}
else
{
allTagSources.AddRange(postFile.Categories);
Console.WriteLine($" -> Erste Kategorie: {postFile.Categories[0].Name}");
}
}
else if (postFile.Categories.Count == 1)
{
allTagSources.AddRange(postFile.Categories);
}
if (postFile.Tags.Count > 0)
allTagSources.AddRange(postFile.Tags);
if (allTagSources.Count > 0)
{
Console.WriteLine($" [Tags] Verarbeite {postFile.Categories.Count} Kategorien + {postFile.Tags.Count} Tags...");
tags = await GhostApi.EnsureTagsAsync(allTagSources);
} }
// 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert

View file

@ -31,6 +31,7 @@ namespace wp2ghost
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; } [JsonPropertyName("published_at")] public string? PublishedAt { get; set; }
[JsonPropertyName("feature_image")] public string? FeatureImage { get; set; } [JsonPropertyName("feature_image")] public string? FeatureImage { get; set; }
[JsonPropertyName("categories")] public List<WpCategory> Categories { get; set; } = new(); [JsonPropertyName("categories")] public List<WpCategory> Categories { get; set; } = new();
[JsonPropertyName("tags")] public List<WpCategory> Tags { get; set; } = new();
[JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("keywords")] public string? Keywords { get; set; }
[JsonPropertyName("comments")] public List<WpComment> Comments { get; set; } = new(); [JsonPropertyName("comments")] public List<WpComment> Comments { get; set; } = new();
[JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new(); [JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new();

View file

@ -99,6 +99,7 @@ namespace wp2ghost
ConvertSingleImages(htmlDoc); ConvertSingleImages(htmlDoc);
ConvertFigures(htmlDoc); ConvertFigures(htmlDoc);
ConvertWpClasses(htmlDoc); ConvertWpClasses(htmlDoc);
AddTargetBlankToExternalLinks(htmlDoc);
return htmlDoc.DocumentNode.OuterHtml; return htmlDoc.DocumentNode.OuterHtml;
} }
@ -134,11 +135,27 @@ namespace wp2ghost
container.AddClass("kg-gallery-container"); container.AddClass("kg-gallery-container");
var wrappers = new List<HtmlNode>(); var wrappers = new List<HtmlNode>();
var imageCaptions = new List<string>();
foreach (var item in items) foreach (var item in items)
{ {
var img = item.SelectSingleNode(".//img"); var img = item.SelectSingleNode(".//img");
if (img == null) continue; if (img == null) continue;
// Bild-Titel aus data-image-caption Attribut extrahieren
string dataCaption = img.GetAttributeValue("data-image-caption", "").Trim();
if (!string.IsNullOrEmpty(dataCaption))
imageCaptions.Add(dataCaption);
// Auch figcaption innerhalb des gallery-items prüfen
var figCap = item.SelectSingleNode(".//figcaption");
if (figCap != null && !string.IsNullOrWhiteSpace(figCap.InnerText))
{
string capText = figCap.InnerText.Trim();
if (!string.IsNullOrEmpty(capText) && !imageCaptions.Contains(capText))
imageCaptions.Add(capText);
}
string src = GetBestImageUrl(img); string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue; if (string.IsNullOrEmpty(src)) continue;
@ -150,11 +167,14 @@ namespace wp2ghost
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container); ghostGallery.AppendChild(container);
var captionNode = gallery.SelectSingleNode(".//*[contains(@class, 'gallery-caption')] | .//figcaption"); // Gallery-Bild-Titel übertragen, wenn vorhanden
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) if (imageCaptions.Count > 0)
{ {
// Ersten nicht-leeren Titel nehmen als Gallery-Kapitel
string firstCaption = imageCaptions[0];
var newCaption = htmlDoc.CreateElement("figcaption"); var newCaption = htmlDoc.CreateElement("figcaption");
newCaption.InnerHtml = captionNode.InnerHtml; newCaption.InnerHtml = firstCaption;
ghostGallery.AppendChild(newCaption); ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption"); ghostGallery.AddClass("kg-card-hascaption");
} }
@ -207,14 +227,23 @@ namespace wp2ghost
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery)); AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
ghostGallery.AppendChild(container); ghostGallery.AppendChild(container);
// Caption aus erstem figcaption im Gallery-Container extrahieren // Alle individuellen Bildunterschriften aus der Gutenberg-Gallery sammeln
var captionNode = gallery.SelectSingleNode(".//figcaption"); var captionNodes = gallery.SelectNodes(".//figcaption");
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText)) if (captionNodes != null && captionNodes.Count > 0)
{ {
var newCaption = htmlDoc.CreateElement("figcaption"); var distinctCaptions = captionNodes
newCaption.InnerHtml = captionNode.InnerHtml; .Select(n => n.InnerText.Trim())
ghostGallery.AppendChild(newCaption); .Where(t => !string.IsNullOrWhiteSpace(t))
ghostGallery.AddClass("kg-card-hascaption"); .Distinct()
.ToList();
if (distinctCaptions.Count > 0)
{
var newCaption = htmlDoc.CreateElement("figcaption");
newCaption.InnerHtml = string.Join(" · ", distinctCaptions);
ghostGallery.AppendChild(newCaption);
ghostGallery.AddClass("kg-card-hascaption");
}
} }
// data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden) // data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden)
@ -567,6 +596,28 @@ namespace wp2ghost
} }
} }
// =========================================================================
// Externe Links mit target="_blank" versehen
// =========================================================================
private static void AddTargetBlankToExternalLinks(HtmlDocument htmlDoc)
{
var anchors = htmlDoc.DocumentNode.SelectNodes("//a[@href]");
if (anchors == null) return;
foreach (var anchor in anchors.ToList())
{
string href = anchor.GetAttributeValue("href", "").Trim();
if (string.IsNullOrEmpty(href)) continue;
// Nur absolute externe URLs (http/https)
if (href.StartsWith("http://") || href.StartsWith("https://"))
{
anchor.SetAttributeValue("target", "_blank");
anchor.SetAttributeValue("rel", "noopener noreferrer");
}
}
}
private static bool HasClass(HtmlNode node, string className) private static bool HasClass(HtmlNode node, string className)
{ {
string classes = node.GetAttributeValue("class", ""); string classes = node.GetAttributeValue("class", "");

View file

@ -20,11 +20,28 @@ namespace wp2ghost
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip; public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
public static bool TryGetRelativePath(string url, out string relativePath) public static bool TryGetRelativePath(string url, out string relativePath)
{ {
// 1. Direkte URL-Prüfung im Cache
if (Cache.TryGetValue(url, out var cached)) if (Cache.TryGetValue(url, out var cached))
{ {
relativePath = cached.RelativePath; relativePath = cached.RelativePath;
return true; return true;
} }
// 2. Prüfung über WordPress-Dateinamen (fürFeatured Images mit anderer URL)
string wpName = Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath);
if (WordpressToGhostImageName.TryGetValue(wpName, out string? relPath) && !string.IsNullOrEmpty(relPath))
{
string localPath = Path.Combine(LocalImageDir, relPath);
if (File.Exists(localPath))
{
relativePath = relPath;
// Auch im Cache eintragen für zukünftige Suche
Cache[url] = (relPath, localPath);
return true;
}
WordpressToGhostImageName.Remove(wpName);
}
relativePath = ""; relativePath = "";
return false; return false;
} }
@ -46,7 +63,10 @@ namespace wp2ghost
{ {
var lines = new List<string>(); var lines = new List<string>();
foreach (var kvp in Cache) foreach (var kvp in Cache)
lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}"); {
string wpName = Path.GetFileNameWithoutExtension(new Uri(kvp.Key).AbsolutePath);
lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{wpName}");
}
File.WriteAllLines(CachePath, lines); File.WriteAllLines(CachePath, lines);
} }
@ -223,8 +243,13 @@ namespace wp2ghost
foreach (var line in lines) foreach (var line in lines)
{ {
var parts = line.Split('|'); var parts = line.Split('|');
if (parts.Length == 3) if (parts.Length >= 3)
{
Cache[parts[0]] = (parts[1], parts[2]); Cache[parts[0]] = (parts[1], parts[2]);
// WordPress-Dateinamen auch ins Dictionary laden
if (parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]))
WordpressToGhostImageName[parts[3]] = parts[1];
}
} }
} }
catch (Exception ex) catch (Exception ex)

View file

@ -0,0 +1,138 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace wp2ghost
{
// =============================================================================
// LinkDecision — gespeicherte Entscheidung für einen Link
// =============================================================================
public class LinkDecision
{
[JsonPropertyName("link_text")]
public string LinkText { get; set; } = "";
[JsonPropertyName("link_href")]
public string LinkHref { get; set; } = "";
// Entscheidung: "keep" (beibehalten), "delete" (löschen), "edit" (ersetzt)
[JsonPropertyName("decision")]
public string Decision { get; set; } = "";
// Nur bei "edit": die neue URL
[JsonPropertyName("new_url")]
public string? NewUrl { get; set; }
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.Now;
}
// =============================================================================
// LinkDecisionManager — merkt sich User-Entscheidungen für Links (Memory)
// =============================================================================
public static class LinkDecisionManager
{
private static Dictionary<string, LinkDecision> _decisions = new(StringComparer.OrdinalIgnoreCase);
private static string _decisionsPath = "";
// Key = Normalisierter Link (Text|Href)
private static string NormalizeKey(string linkText, string linkHref)
{
return $"{linkText.Trim().ToLowerInvariant()}|{linkHref.Trim().ToLowerInvariant()}";
}
public static void Init(string baseDir)
{
_decisionsPath = Path.Combine(baseDir, "link_decisions.json");
Load();
}
public static void Save()
{
if (string.IsNullOrEmpty(_decisionsPath)) return;
try
{
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(_decisionsPath, JsonSerializer.Serialize(_decisions.Values.ToList(), options));
}
catch (Exception ex)
{
Console.WriteLine($" [Link-Memory] Fehler beim Speichern der Entscheidungen: {ex.Message}");
}
}
private static void Load()
{
if (!File.Exists(_decisionsPath)) return;
try
{
var json = File.ReadAllText(_decisionsPath);
var list = JsonSerializer.Deserialize<List<LinkDecision>>(json);
if (list != null)
{
foreach (var d in list)
{
string key = NormalizeKey(d.LinkText, d.LinkHref);
_decisions[key] = d;
}
}
}
catch (Exception ex)
{
Console.WriteLine($" [Link-Memory] Fehler beim Laden der Entscheidungen: {ex.Message}");
}
}
/// <summary>
/// Speichert eine User-Entscheidung für einen Link.
/// </summary>
public static void SaveDecision(string linkText, string linkHref, string decision, string? newUrl = null)
{
string key = NormalizeKey(linkText, linkHref);
_decisions[key] = new LinkDecision
{
LinkText = linkText,
LinkHref = linkHref,
Decision = decision,
NewUrl = newUrl
};
Save();
}
/// <summary>
/// Versucht eine frühere Entscheidung für diesen Link zu finden.
/// </summary>
public static LinkDecision? GetPreviousDecision(string linkText, string linkHref)
{
string key = NormalizeKey(linkText, linkHref);
if (_decisions.TryGetValue(key, out var d))
return d;
return null;
}
/// <summary>
/// Gibt die Anzahl gespeicherter Entscheidungen zurück (für Info-Anzeige).
/// </summary>
public static int Count => _decisions.Count;
public static void ShowInfo()
{
if (_decisions.Count > 0)
{
Console.WriteLine($"\nGespeicherte Link-Entscheidungen: {_decisions.Count}");
foreach (var kvp in _decisions.Take(20))
{
var d = kvp.Value;
string info = d.Decision switch
{
"delete" => "gelöscht",
"edit" => $"ersetzt durch '{d.NewUrl}'",
_ => "beibehalten"
};
Console.WriteLine($" \"{d.LinkText}\" -> {info}");
}
if (_decisions.Count > 20)
Console.WriteLine($" ... und {_decisions.Count - 20} weitere");
}
}
}
}

View file

@ -78,6 +78,9 @@ namespace wp2ghost
return; return;
} }
ProcessedPosts.Clear();
LoadProcessedPosts();
var pendingIds = allPostIds.Where(id => !ProcessedPosts.Contains(id)).ToList(); var pendingIds = allPostIds.Where(id => !ProcessedPosts.Contains(id)).ToList();
Console.WriteLine($"Insgesamt gefunden: {allPostIds.Count}, Bereits abgefragt: {allPostIds.Count - pendingIds.Count}, Noch zu verarbeiten: {pendingIds.Count}"); Console.WriteLine($"Insgesamt gefunden: {allPostIds.Count}, Bereits abgefragt: {allPostIds.Count - pendingIds.Count}, Noch zu verarbeiten: {pendingIds.Count}");
@ -105,11 +108,15 @@ namespace wp2ghost
Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}..."); Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}...");
string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}"; string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}";
//string postUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/wp/v2/posts/{wpId}?_embed";
try try
{ {
var detailJson = await httpClient.GetStringAsync(detailUrl); var detailJson = await httpClient.GetStringAsync(detailUrl);
var wpPost = JsonSerializer.Deserialize<WpPostResponse>(detailJson); var wpPost = JsonSerializer.Deserialize<WpPostResponse>(detailJson);
//var postJson = await httpClient.GetStringAsync(postUrl);
if (wpPost == null) if (wpPost == null)
{ {
Console.WriteLine($" Übersprungen (null)."); Console.WriteLine($" Übersprungen (null).");
@ -217,7 +224,7 @@ namespace wp2ghost
GhostPosts.Add(ghostPost); GhostPosts.Add(ghostPost);
// JSON-Datei in ghostPosts/ speichern // JSON-Datei in ghostPosts/ speichern
var postFile = new GhostPostFile var ghostPostFile = new GhostPostFile
{ {
WpId = wpId, WpId = wpId,
WpSlug = wpPost.Slug, WpSlug = wpPost.Slug,
@ -230,13 +237,17 @@ namespace wp2ghost
PublishedAt = postDate.ToString("o"), PublishedAt = postDate.ToString("o"),
FeatureImage = featureImageGhostPath, FeatureImage = featureImageGhostPath,
Categories = wpPost.Categories ?? new(), Categories = wpPost.Categories ?? new(),
Tags = wpPost.Tags ?? new(),
Keywords = wpPost.Keywords ?? "", Keywords = wpPost.Keywords ?? "",
Comments = wpPost.Comments ?? new(), Comments = wpPost.Comments ?? new(),
ImageFiles = imageFiles, ImageFiles = imageFiles,
Html = htmlContent Html = htmlContent
}; };
string jsonPath = Path.Combine(GhostPostsDir, $"{postFile.WpId}.json"); string jsonPath = Path.Combine(GhostPostsDir, $"{ghostPostFile.WpId}.json");
File.WriteAllText(jsonPath, JsonSerializer.Serialize(postFile, new JsonSerializerOptions { WriteIndented = true })); File.WriteAllText(jsonPath, JsonSerializer.Serialize(ghostPostFile, new JsonSerializerOptions { WriteIndented = true }));
// Cache sofort speichern (nicht erst beim Beenden)
ImageManager.SaveCache();
MarkProcessed(wpId, wpPost.Slug); MarkProcessed(wpId, wpPost.Slug);
internalId++; internalId++;

View file

@ -24,6 +24,7 @@ namespace wp2ghost
[JsonPropertyName("excerpt")] public string? Excerpt { get; set; } [JsonPropertyName("excerpt")] public string? Excerpt { get; set; }
[JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; } [JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; }
[JsonPropertyName("categories")] public List<WpCategory>? Categories { get; set; } = new(); [JsonPropertyName("categories")] public List<WpCategory>? Categories { get; set; } = new();
[JsonPropertyName("tags")] public List<WpCategory>? Tags { get; set; } = new();
[JsonPropertyName("keywords")] public string? Keywords { get; set; } [JsonPropertyName("keywords")] public string? Keywords { get; set; }
[JsonPropertyName("comments")] public List<WpComment>? Comments { get; set; } = new(); [JsonPropertyName("comments")] public List<WpComment>? Comments { get; set; } = new();
[JsonPropertyName("media_files")] public List<string>? MediaFiles { get; set; } = new(); [JsonPropertyName("media_files")] public List<string>? MediaFiles { get; set; } = new();

View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["GhostImageManager.styles.css"]" />
<ImportMap />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script>
window.downloadFileFromBase64 = (fileName, base64, contentType) => {
const link = document.createElement('a');
link.href = 'data:' + contentType + ';base64,' + base64;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
</script>
</body>
</html>

View file

@ -0,0 +1,9 @@
@inherits LayoutComponentBase
@Body
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View file

@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<h3>Ghost Image Manager</h3>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View file

@ -0,0 +1,20 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View file

@ -0,0 +1,45 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">Ghost Image Manager</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Startseite
</NavLink>
</div>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="images">
<span class="bi bi-images" aria-hidden="true"></span> Bilder
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="settings">
<span class="bi bi-gear-fill" aria-hidden="true"></span> Einstellungen
</NavLink>
</div>
<div class="nav-item px-3 mt-3">
<form method="post" action="/auth/logout" class="d-inline">
<button type="submit" class="nav-link btn btn-link text-start w-100">
<span class="bi bi-box-arrow-right" aria-hidden="true"></span> Abmelden
</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="login">
<span class="bi bi-box-arrow-in-right" aria-hidden="true"></span> Anmelden
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>

View file

@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>

View file

@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View file

@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View file

@ -0,0 +1,9 @@
@page "/access-denied"
<PageTitle>Zugriff verweigert</PageTitle>
<div class="alert alert-danger mt-5">
<h4>Zugriff verweigert</h4>
<p>Du hast nicht die Berechtigung, auf diese Seite zuzugreifen.</p>
<a href="/login" class="btn btn-primary">Zur Anmeldung</a>
</div>

View file

@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View file

@ -0,0 +1,55 @@
@page "/"
@attribute [Authorize]
@inject GhostConfigService ConfigService
<PageTitle>Startseite</PageTitle>
<div class="jumbotron bg-light p-5 rounded">
<h1 class="display-4">Ghost Image Manager</h1>
<p class="lead">
Verwalte Bilder deiner Ghost-Instanzen direkt vom Server aus.
</p>
<hr class="my-4" />
<p>
Funktionen: Bilder auflisten, nach Posts suchen, verwaiste Bilder finden und Bilder austauschen.
</p>
<a class="btn btn-primary btn-lg" href="images" role="button">Zu den Bildern</a>
<a class="btn btn-outline-secondary btn-lg" href="settings" role="button">Einstellungen</a>
</div>
@if (_config.Instances.Count == 0)
{
<div class="alert alert-warning mt-4">
<strong>Hinweis:</strong> Es ist noch keine Ghost-Instanz konfiguriert.
Bitte hinterlege unter <a href="settings">Einstellungen</a> deine Ghost-URL, den Admin API Key und den lokalen Image-Ordner.
</div>
}
else
{
<div class="row mt-4">
@foreach (var instance in _config.Instances)
{
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">@instance.Name</h5>
<p class="card-text">
<strong>URL:</strong> @instance.Url<br />
<strong>Image-Ordner:</strong> <span class="text-truncate d-block" title="@instance.ImageFolderPath">@instance.ImageFolderPath</span>
</p>
<a href="images" class="btn btn-primary">Bilder anzeigen</a>
</div>
</div>
</div>
}
</div>
}
@code {
private GhostConfig _config = new();
protected override void OnInitialized()
{
_config = ConfigService.Load();
}
}

View file

@ -0,0 +1,625 @@
@page "/images"
@attribute [Authorize]
@rendermode InteractiveServer
@inject GhostConfigService ConfigService
@inject ImageAnalyzerService ImageAnalyzer
@inject ImageReplacementService ReplacementService
@inject ImageDeletionService DeletionService
@inject ImageExportService ExportService
@inject IJSRuntime JsRuntime
@inject ILogger<Images> Logger
<PageTitle>Ghost Bilder</PageTitle>
<h1>Ghost Bilderverwaltung</h1>
@if (_instances.Count == 0)
{
<div class="alert alert-warning">
Keine Ghost-Instanzen konfiguriert. Bitte zuerst unter <a href="settings">Einstellungen</a> eine Instanz anlegen.
</div>
}
else
{
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Ghost-Instanz</label>
<select class="form-select" @bind="_selectedInstanceId" @bind:after="OnInstanceChanged">
@foreach (var instance in _instances)
{
<option value="@instance.Id">@instance.Name</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Suche nach Post</label>
<div class="input-group">
<input type="text" class="form-control" @bind="_searchTerm" @bind:event="oninput" placeholder="Post-Titel..." />
<button class="btn btn-outline-secondary" @onclick="LoadImages" disabled="@_isLoading">Suchen</button>
</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<button class="btn btn-primary me-2" @onclick="LoadImages" disabled="@_isLoading">
@if (_isLoading)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Lade...</span>
}
else
{
<span>Alle Bilder laden</span>
}
</button>
@if (FilteredImages.Any())
{
<div class="btn-group me-2">
<button class="btn btn-outline-success dropdown-toggle" data-bs-toggle="dropdown">
Exportieren
</button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" @onclick="ExportCsv">CSV exportieren</button></li>
<li><button class="dropdown-item" @onclick="ExportTxt">TXT exportieren</button></li>
<li><button class="dropdown-item" @onclick="ExportSelectedZip">Ausgewählte als ZIP</button></li>
</ul>
</div>
@if (SelectedOrphanedImages.Any())
{
<button class="btn btn-danger me-2" @onclick="DeleteSelectedOrphaned">
Auswahl löschen (@SelectedOrphanedImages.Count)
</button>
}
}
</div>
</div>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger">@_errorMessage</div>
}
@if (!string.IsNullOrEmpty(_successMessage))
{
<div class="alert alert-success">@_successMessage</div>
}
@if (_allImages.Count > 0)
{
<div class="row mb-3 align-items-center">
<div class="col">
<div class="btn-group" role="group">
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.All)" @onclick="() => SetFilter(ImageFilter.All)">Alle (@_allImages.Count)</button>
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Feature)" @onclick="() => SetFilter(ImageFilter.Feature)">Feature (@_featureCount)</button>
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Content)" @onclick="() => SetFilter(ImageFilter.Content)">Content (@_contentCount)</button>
<button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button>
</div>
</div>
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllOrphaned" @onchange="ToggleSelectAllOrphaned" checked="@_selectAllOrphaned" />
<label class="form-check-label" for="selectAllOrphaned">Alle verwaisten auswählen</label>
</div>
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
@foreach (var image in FilteredImages)
{
<div class="col">
<div class="card h-100 @GetCardClass(image)">
@if (image.IsOrphaned)
{
<div class="card-header py-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" checked="@_selectedImageUrls.Contains(image.Url)" @onchange="e => ToggleSelection(image, e)" id="chk-@image.GetHashCode()" />
<label class="form-check-label small" for="chk-@image.GetHashCode()">Auswählen</label>
</div>
</div>
}
<div class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 200px;">
<img src="@image.Url" alt="@image.FileName" class="img-fluid" style="max-height: 100%; max-width: 100%; object-fit: contain;" loading="lazy" />
</div>
<div class="card-body">
<h5 class="card-title text-truncate" title="@image.FileName">@image.FileName</h5>
<p class="card-text small text-muted">
<strong>Pfad:</strong> <span class="text-truncate d-block" title="@image.RelativePath">@image.RelativePath</span>
<strong>Größe:</strong> @image.FormattedFileSize<br />
<strong>Dimensionen:</strong> @image.Dimensions<br />
<strong>Datum:</strong> @image.LastModified.ToString("dd.MM.yyyy HH:mm")
</p>
@if (image.IsOrphaned)
{
<div class="alert alert-warning py-1 px-2 mb-2 small">
⚠️ Verwaist (nicht in Posts verwendet)
</div>
}
else
{
<div class="mb-2">
<small class="text-muted">Verwendet in:</small>
<ul class="list-unstyled small mb-0">
@foreach (var post in image.UsedInPosts.Take(3))
{
<li>
<span class="badge @(post.IsFeatureImage ? "bg-primary" : "bg-secondary") me-1">
@(post.IsFeatureImage ? "Feature" : "Content")
</span>
<span title="@post.Title">@post.Title</span>
</li>
}
@if (image.UsedInPosts.Count > 3)
{
<li class="text-muted">... und @(image.UsedInPosts.Count - 3) weitere</li>
}
</ul>
</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="() => ShowReplaceDialog(image)">
Tauschen
</button>
@if (image.IsOrphaned)
{
<button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)">
Löschen
</button>
}
</div>
</div>
</div>
</div>
}
</div>
}
else if (!_isLoading && _hasLoaded)
{
<div class="alert alert-info">Keine Bilder gefunden.</div>
}
}
@if (_showReplaceDialog && _selectedImage != null)
{
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Bild tauschen: @_selectedImage.FileName</h5>
<button type="button" class="btn-close" @onclick="CloseReplaceDialog"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<h6>Aktuelles Bild</h6>
<img src="@_selectedImage.Url" class="img-fluid border" alt="Aktuell" />
<p class="small text-muted mt-2">
@_selectedImage.FormattedFileSize | @_selectedImage.Dimensions
</p>
</div>
<div class="col-md-6">
<h6>Neues Bild</h6>
<InputFile OnChange="LoadNewImage" class="form-control" accept="image/*" />
@if (_newImagePreview != null)
{
<img src="@_newImagePreview" class="img-fluid border mt-2" alt="Vorschau" />
}
</div>
</div>
@if (!string.IsNullOrEmpty(_replaceError))
{
<div class="alert alert-danger mt-3">@_replaceError</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseReplaceDialog">Abbrechen</button>
<button type="button" class="btn btn-primary" @onclick="ReplaceImage" disabled="@(_newImageFile == null || _isReplacing)">
@if (_isReplacing)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Tausche...</span>
}
else
{
<span>Hochladen & Ersetzen</span>
}
</button>
</div>
</div>
</div>
</div>
}
@if (_showDeleteDialog && _selectedImage != null)
{
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Bild löschen</h5>
<button type="button" class="btn-close" @onclick="CloseDeleteDialog"></button>
</div>
<div class="modal-body">
<p>Möchtest du wirklich <strong>@_selectedImage.FileName</strong> löschen?</p>
<div class="alert alert-warning">
⚠️ Dieses Bild ist verwaist und wird in keinem Post verwendet.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseDeleteDialog">Abbrechen</button>
<button type="button" class="btn btn-danger" @onclick="DeleteImage">
Löschen
</button>
</div>
</div>
</div>
</div>
}
@if (_showBulkDeleteDialog)
{
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ausgewählte Bilder löschen</h5>
<button type="button" class="btn-close" @onclick="CloseBulkDeleteDialog"></button>
</div>
<div class="modal-body">
<p>Möchtest du wirklich <strong>@SelectedOrphanedImages.Count</strong> verwaiste Bilder löschen?</p>
<ul class="list-group small">
@foreach (var img in SelectedOrphanedImages.Take(10))
{
<li class="list-group-item">@img.FileName</li>
}
@if (SelectedOrphanedImages.Count > 10)
{
<li class="list-group-item text-muted">... und @(SelectedOrphanedImages.Count - 10) weitere</li>
}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseBulkDeleteDialog">Abbrechen</button>
<button type="button" class="btn btn-danger" @onclick="DeleteSelectedOrphanedConfirmed" disabled="@_isDeleting">
@if (_isDeleting)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Lösche...</span>
}
else
{
<span>Alle löschen</span>
}
</button>
</div>
</div>
</div>
</div>
}
@code {
private List<GhostInstance> _instances = new();
private string _selectedInstanceId = "";
private string _searchTerm = "";
private List<GhostImage> _allImages = new();
private bool _isLoading;
private bool _hasLoaded;
private string? _errorMessage;
private string? _successMessage;
private ImageFilter _currentFilter = ImageFilter.All;
private int _orphanedCount;
private int _featureCount;
private int _contentCount;
private GhostImage? _selectedImage;
private bool _showReplaceDialog;
private bool _showDeleteDialog;
private bool _showBulkDeleteDialog;
private IBrowserFile? _newImageFile;
private string? _newImagePreview;
private string? _replaceError;
private bool _isReplacing;
private bool _isDeleting;
private HashSet<string> _selectedImageUrls = new();
private bool _selectAllOrphaned;
protected override void OnInitialized()
{
var config = ConfigService.Load();
_instances = config.Instances;
_selectedInstanceId = _instances.FirstOrDefault()?.Id ?? "";
}
private async Task OnInstanceChanged()
{
_allImages.Clear();
_hasLoaded = false;
_selectedImageUrls.Clear();
await LoadImages();
}
private async Task LoadImages()
{
if (string.IsNullOrEmpty(_selectedInstanceId))
return;
_isLoading = true;
_errorMessage = null;
_successMessage = null;
StateHasChanged();
try
{
var instance = _instances.First(i => i.Id == _selectedInstanceId);
var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'";
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter);
CalculateCounts();
_hasLoaded = true;
}
catch (Exception ex)
{
Logger.LogError(ex, "Fehler beim Laden der Bilder");
_errorMessage = $"Fehler: {ex.Message}";
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private void CalculateCounts()
{
_orphanedCount = _allImages.Count(i => i.IsOrphaned);
_featureCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsFeatureImage));
_contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent));
}
private List<GhostImage> FilteredImages => _currentFilter switch
{
ImageFilter.Orphaned => _allImages.Where(i => i.IsOrphaned).ToList(),
ImageFilter.Feature => _allImages.Where(i => i.UsedInPosts.Any(r => r.IsFeatureImage)).ToList(),
ImageFilter.Content => _allImages.Where(i => i.UsedInPosts.Any(r => r.IsInContent)).ToList(),
_ => _allImages
};
private List<GhostImage> SelectedOrphanedImages => _allImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.IsOrphaned).ToList();
private void SetFilter(ImageFilter filter)
{
_currentFilter = filter;
}
private string GetFilterClass(ImageFilter filter) => _currentFilter == filter ? "active" : "";
private string GetCardClass(GhostImage image) => image.IsOrphaned ? "border-danger" : "";
private void ToggleSelection(GhostImage image, ChangeEventArgs e)
{
var isSelected = e.Value is bool b && b;
if (isSelected)
_selectedImageUrls.Add(image.Url);
else
_selectedImageUrls.Remove(image.Url);
_selectAllOrphaned = false;
StateHasChanged();
}
private void ToggleSelectAllOrphaned(ChangeEventArgs e)
{
_selectAllOrphaned = e.Value is bool b && b;
if (_selectAllOrphaned)
{
foreach (var image in _allImages.Where(i => i.IsOrphaned))
_selectedImageUrls.Add(image.Url);
}
else
{
foreach (var image in _allImages.Where(i => i.IsOrphaned))
_selectedImageUrls.Remove(image.Url);
}
StateHasChanged();
}
private void ShowReplaceDialog(GhostImage image)
{
_selectedImage = image;
_showReplaceDialog = true;
_newImageFile = null;
_newImagePreview = null;
_replaceError = null;
}
private void CloseReplaceDialog()
{
_showReplaceDialog = false;
_selectedImage = null;
_newImageFile = null;
_newImagePreview = null;
_replaceError = null;
}
private void ShowDeleteDialog(GhostImage image)
{
_selectedImage = image;
_showDeleteDialog = true;
}
private void CloseDeleteDialog()
{
_showDeleteDialog = false;
_selectedImage = null;
}
private void ShowBulkDeleteDialog()
{
_showBulkDeleteDialog = true;
}
private void CloseBulkDeleteDialog()
{
_showBulkDeleteDialog = false;
}
private void DeleteSelectedOrphaned()
{
if (SelectedOrphanedImages.Any())
ShowBulkDeleteDialog();
}
private async Task DeleteSelectedOrphanedConfirmed()
{
_isDeleting = true;
StateHasChanged();
try
{
var result = await DeletionService.DeleteImagesAsync(SelectedOrphanedImages);
_selectedImageUrls.Clear();
_selectAllOrphaned = false;
_successMessage = $"{result.Success} Bilder gelöscht, {result.Failed} fehlgeschlagen.";
await LoadImages();
}
catch (Exception ex)
{
_errorMessage = $"Fehler beim Löschen: {ex.Message}";
}
finally
{
_isDeleting = false;
_showBulkDeleteDialog = false;
StateHasChanged();
}
}
private async Task DeleteImage()
{
if (_selectedImage == null) return;
try
{
var success = await DeletionService.DeleteImageAsync(_selectedImage);
if (success)
{
_selectedImageUrls.Remove(_selectedImage.Url);
_successMessage = $"{_selectedImage.FileName} gelöscht.";
CloseDeleteDialog();
await LoadImages();
}
else
{
_errorMessage = "Bild konnte nicht gelöscht werden.";
}
}
catch (Exception ex)
{
_errorMessage = $"Fehler beim Löschen: {ex.Message}";
}
}
private async Task LoadNewImage(InputFileChangeEventArgs e)
{
_newImageFile = e.File;
_replaceError = null;
try
{
using var stream = _newImageFile.OpenReadStream(maxAllowedSize: 104857600);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
_newImagePreview = $"data:{_newImageFile.ContentType};base64,{Convert.ToBase64String(bytes)}";
}
catch (Exception ex)
{
_replaceError = $"Fehler beim Lesen der Datei: {ex.Message}";
}
StateHasChanged();
}
private async Task ReplaceImage()
{
if (_selectedImage == null || _newImageFile == null)
return;
_isReplacing = true;
_replaceError = null;
StateHasChanged();
try
{
using var stream = _newImageFile.OpenReadStream(maxAllowedSize: 104857600);
var success = await ReplacementService.ReplaceImageAsync(_selectedImage, stream, _newImageFile.Name);
if (success)
{
await LoadImages();
CloseReplaceDialog();
}
else
{
_replaceError = "Bild konnte nicht ersetzt werden.";
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Fehler beim Tauschen des Bildes");
_replaceError = $"Fehler: {ex.Message}";
}
finally
{
_isReplacing = false;
StateHasChanged();
}
}
private async Task ExportCsv()
{
var data = ExportService.ExportToCsv(FilteredImages);
await DownloadFileAsync($"ghost-images-{DateTime.Now:yyyyMMdd-HHmm}.csv", data, "text/csv");
}
private async Task ExportTxt()
{
var data = ExportService.ExportToTxt(FilteredImages);
await DownloadFileAsync($"ghost-images-{DateTime.Now:yyyyMMdd-HHmm}.txt", data, "text/plain");
}
private async Task ExportSelectedZip()
{
var selectedImages = _allImages.Where(i => _selectedImageUrls.Contains(i.Url)).ToList();
if (!selectedImages.Any())
{
_errorMessage = "Keine Bilder ausgewählt.";
return;
}
var data = await ExportService.ExportToZipAsync(selectedImages);
await DownloadFileAsync($"ghost-images-{DateTime.Now:yyyyMMdd-HHmm}.zip", data, "application/zip");
}
private async Task DownloadFileAsync(string fileName, byte[] data, string contentType)
{
var base64 = Convert.ToBase64String(data);
await JsRuntime.InvokeVoidAsync("downloadFileFromBase64", fileName, base64, contentType);
}
private static string EscapeFilter(string term)
{
return term.Replace("'", "\\'");
}
private enum ImageFilter
{
All,
Feature,
Content,
Orphaned
}
}

View file

@ -0,0 +1,59 @@
@page "/login"
@layout Layout.EmptyLayout
@inject IWebHostEnvironment Env
<PageTitle>Anmelden</PageTitle>
<div class="login-container">
<!-- Hintergrundbild -->
@if (_imageExists)
{
<img src="/start-image/start.jpg" alt="Startbild" class="login-background" />
}
else
{
<div class="login-background login-background-placeholder">
<div class="text-center text-white">
<i class="bi bi-image display-1"></i>
<p class="mt-3">Bitte lege eine Datei namens <code>start.jpg</code> im Root-Verzeichnis der App ab.</p>
</div>
</div>
}
<!-- Login-Formular unten mittig -->
<div class="login-form-wrapper">
<div class="login-form-card">
<h4 class="mb-3 text-white">Ghost Image Manager</h4>
<form method="post" action="/auth/login">
<div class="mb-3">
<label class="form-label text-white">Passwort</label>
<input type="password" name="password" class="form-control" placeholder="Passwort eingeben" required />
</div>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger">@ErrorMessage</div>
}
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
</form>
</div>
</div>
</div>
@code {
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
[SupplyParameterFromQuery]
public string? ErrorMessage { get; set; }
private bool _imageExists;
protected override void OnInitialized()
{
var imagePath = Path.Combine(Env.ContentRootPath, "start.jpg");
_imageExists = File.Exists(imagePath);
}
}

View file

@ -0,0 +1,10 @@
@page "/logout"
<PageTitle>Abmelden</PageTitle>
<div class="text-center mt-5">
<h3>Abmelden</h3>
<form method="post" action="/auth/logout">
<button type="submit" class="btn btn-primary">Jetzt abmelden</button>
</form>
</div>

View file

@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View file

@ -0,0 +1,192 @@
@page "/settings"
@attribute [Authorize]
@rendermode InteractiveServer
@inject GhostConfigService ConfigService
<PageTitle>Einstellungen</PageTitle>
<h1>Ghost-Instanzen verwalten</h1>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Konfigurierte Instanzen</h5>
</div>
<div class="card-body">
@if (_config.Instances.Count == 0)
{
<div class="alert alert-info">Keine Instanzen konfiguriert.</div>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Bilderverzeichnis</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
@foreach (var instance in _config.Instances)
{
<tr>
<td>@instance.Name</td>
<td>@instance.Url</td>
<td class="text-truncate" style="max-width: 300px;" title="@instance.ImageFolderPath">@instance.ImageFolderPath</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditInstance(instance)">Bearbeiten</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteInstance(instance)">Löschen</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">@(_editingInstance.Id == null || _isNew ? "Neue Instanz" : "Instanz bearbeiten")</h5>
</div>
<div class="card-body">
<EditForm Model="_editingInstance" OnValidSubmit="SaveInstance">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label class="form-label">Name</label>
<InputText @bind-Value="_editingInstance.Name" class="form-control" placeholder="z.B. Produktion" />
</div>
<div class="mb-3">
<label class="form-label">Ghost URL</label>
<InputText @bind-Value="_editingInstance.Url" class="form-control" placeholder="https://mein-blog.de" />
</div>
<div class="mb-3">
<label class="form-label">Admin API Key</label>
<InputText @bind-Value="_editingInstance.AdminApiKey" class="form-control" type="password" placeholder="id:secret" />
<div class="form-text">Format: {id}:{secret} aus den Ghost-Integrationen</div>
</div>
<div class="mb-3">
<label class="form-label">Content API Key (optional)</label>
<InputText @bind-Value="_editingInstance.ContentApiKey" class="form-control" placeholder="Optional" />
</div>
<div class="mb-3">
<label class="form-label">Lokaler Image-Ordner</label>
<InputText @bind-Value="_editingInstance.ImageFolderPath" class="form-control" placeholder="C:\\Ghost\\content\\images" />
<div class="form-text">Vollständiger Pfad zum Ghost content/images Ordner</div>
</div>
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-secondary" @onclick="ResetForm">Zurücksetzen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</EditForm>
@if (!string.IsNullOrEmpty(_message))
{
<div class="alert alert-success mt-3">@_message</div>
}
</div>
</div>
</div>
</div>
@code {
private GhostConfig _config = new();
private GhostInstanceForm _editingInstance = new();
private bool _isNew = true;
private string? _message;
protected override void OnInitialized()
{
LoadConfig();
}
private void LoadConfig()
{
_config = ConfigService.Load();
}
private void EditInstance(GhostInstance instance)
{
_editingInstance = new GhostInstanceForm
{
Id = instance.Id,
Name = instance.Name,
Url = instance.Url,
AdminApiKey = instance.AdminApiKey,
ContentApiKey = instance.ContentApiKey,
ImageFolderPath = instance.ImageFolderPath
};
_isNew = false;
_message = null;
}
private void DeleteInstance(GhostInstance instance)
{
_config.Instances.Remove(instance);
ConfigService.Save(_config);
LoadConfig();
ResetForm();
}
private void SaveInstance()
{
if (_isNew)
{
_config.Instances.Add(new GhostInstance
{
Id = Guid.NewGuid().ToString("N")[..8],
Name = _editingInstance.Name,
Url = _editingInstance.Url.TrimEnd('/'),
AdminApiKey = _editingInstance.AdminApiKey,
ContentApiKey = _editingInstance.ContentApiKey,
ImageFolderPath = _editingInstance.ImageFolderPath
});
}
else
{
var existing = _config.Instances.FirstOrDefault(i => i.Id == _editingInstance.Id);
if (existing != null)
{
existing.Name = _editingInstance.Name;
existing.Url = _editingInstance.Url.TrimEnd('/');
existing.AdminApiKey = _editingInstance.AdminApiKey;
existing.ContentApiKey = _editingInstance.ContentApiKey;
existing.ImageFolderPath = _editingInstance.ImageFolderPath;
}
}
ConfigService.Save(_config);
LoadConfig();
ResetForm();
_message = "Instanz gespeichert.";
}
private void ResetForm()
{
_editingInstance = new GhostInstanceForm();
_isNew = true;
_message = null;
}
private class GhostInstanceForm
{
public string? Id { get; set; }
public string Name { get; set; } = "";
public string Url { get; set; } = "";
public string AdminApiKey { get; set; } = "";
public string ContentApiKey { get; set; } = "";
public string ImageFolderPath { get; set; } = "";
}
}

View file

@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View file

@ -0,0 +1,15 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using GhostImageManager
@using GhostImageManager.Components
@using GhostImageManager.Components.Layout
@using GhostImageManager.Models
@using GhostImageManager.Services

View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.66" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\favicon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<None Update="logo.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="start.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>https</ActiveDebugProfile>
<NameOfLastUsedPublishProfile>E:\Wp2Ghost\GhostImageManager\Properties\PublishProfiles\FolderProfile.pubxml</NameOfLastUsedPublishProfile>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,7 @@
namespace GhostImageManager.Models;
public class AppSettings
{
public string Password { get; set; } = "change-me-in-production";
public int SessionTimeoutMinutes { get; set; } = 480;
}

View file

@ -0,0 +1,6 @@
namespace GhostImageManager.Models;
public class GhostConfig
{
public List<GhostInstance> Instances { get; set; } = new();
}

View file

@ -0,0 +1,33 @@
namespace GhostImageManager.Models;
public class GhostImage
{
public string Url { get; set; } = "";
public string FileName { get; set; } = "";
public string RelativePath { get; set; } = "";
public string FilePath { get; set; } = "";
public long FileSize { get; set; }
public DateTime LastModified { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public bool IsOrphaned { get; set; } = true;
public List<PostReference> UsedInPosts { get; set; } = new();
public string FormattedFileSize => FileSize switch
{
>= 1024 * 1024 => $"{FileSize / (1024.0 * 1024.0):F2} MB",
>= 1024 => $"{FileSize / 1024.0:F2} KB",
_ => $"{FileSize} B"
};
public string Dimensions => Width > 0 && Height > 0 ? $"{Width}x{Height}" : "n/a";
}
public class PostReference
{
public string PostId { get; set; } = "";
public string Title { get; set; } = "";
public string Slug { get; set; } = "";
public bool IsFeatureImage { get; set; }
public bool IsInContent { get; set; }
}

View file

@ -0,0 +1,11 @@
namespace GhostImageManager.Models;
public class GhostInstance
{
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
public string Name { get; set; } = "";
public string Url { get; set; } = "";
public string AdminApiKey { get; set; } = "";
public string ContentApiKey { get; set; } = "";
public string ImageFolderPath { get; set; } = "";
}

View file

@ -0,0 +1,13 @@
namespace GhostImageManager.Models;
public class GhostPost
{
public string Id { get; set; } = "";
public string Title { get; set; } = "";
public string Slug { get; set; } = "";
public string Html { get; set; } = "";
public string? FeatureImage { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string Status { get; set; } = "";
}

View file

@ -0,0 +1,102 @@
using GhostImageManager.Components;
using GhostImageManager.Models;
using GhostImageManager.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings"));
builder.Services.AddSingleton<GhostConfigService>();
builder.Services.AddScoped<GhostApiService>();
builder.Services.AddScoped<ImageAnalyzerService>();
builder.Services.AddScoped<ImageReplacementService>();
builder.Services.AddScoped<ImageDeletionService>();
builder.Services.AddScoped<ImageExportService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddHttpClient<GhostApiService>();
// Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/access-denied";
options.ExpireTimeSpan = TimeSpan.FromMinutes(480);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
// Upload-Limit erhöhen (für Bildtausch)
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 104857600; // 100 MB
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
// Standard wwwroot
app.UseStaticFiles();
// Zusätzlich: start.jpg aus dem App-Root ausliefern
var rootImagePath = Path.Combine(app.Environment.ContentRootPath, "start.jpg");
if (File.Exists(rootImagePath))
{
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(app.Environment.ContentRootPath),
RequestPath = "/start-image"
});
}
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
// Authentication API endpoints
app.MapPost("/auth/login", async (HttpContext context, AuthService authService) =>
{
var form = await context.Request.ReadFormAsync();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
if (string.IsNullOrEmpty(returnUrl))
returnUrl = "/";
try
{
await authService.SignInAsync(context, password);
return Results.Redirect(returnUrl);
}
catch (UnauthorizedAccessException)
{
return Results.Redirect($"/login?errorMessage={Uri.EscapeDataString("Ungültiges Passwort.")}&returnUrl={Uri.EscapeDataString(returnUrl)}");
}
});
app.MapPost("/auth/logout", async (HttpContext context, AuthService authService) =>
{
await authService.SignOutAsync(context);
return Results.Redirect("/login");
});
app.Run();

View file

@ -0,0 +1,51 @@
using System.Security.Claims;
using GhostImageManager.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace GhostImageManager.Services;
public class AuthService
{
private readonly AppSettings _appSettings;
public AuthService(IConfiguration configuration)
{
_appSettings = configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();
}
public bool ValidatePassword(string password)
{
return !string.IsNullOrEmpty(password) &&
password == _appSettings.Password;
}
public async Task SignInAsync(HttpContext context, string password)
{
if (!ValidatePassword(password))
throw new UnauthorizedAccessException("Ungültiges Passwort.");
var claims = new List<Claim>
{
new(ClaimTypes.Name, "Admin"),
new(ClaimTypes.Role, "Administrator")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(_appSettings.SessionTimeoutMinutes)
});
}
public async Task SignOutAsync(HttpContext context)
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}

View file

@ -0,0 +1,169 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GhostImageManager.Models;
using HtmlAgilityPack;
namespace GhostImageManager.Services;
public class GhostApiService
{
private readonly HttpClient _httpClient;
public GhostApiService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<GhostPost>> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 100)
{
var request = CreateRequest(HttpMethod.Get, instance, $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,feature_image,html,created_at,updated_at,status");
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var posts = new List<GhostPost>();
if (doc.RootElement.TryGetProperty("posts", out var postsElement))
{
foreach (var post in postsElement.EnumerateArray())
{
posts.Add(new GhostPost
{
Id = GetString(post, "id"),
Title = GetString(post, "title"),
Slug = GetString(post, "slug"),
Html = GetString(post, "html"),
FeatureImage = GetString(post, "feature_image"),
CreatedAt = GetDateTime(post, "created_at"),
UpdatedAt = GetDateTime(post, "updated_at"),
Status = GetString(post, "status")
});
}
}
return posts;
}
public async Task<GhostPost?> GetPostAsync(GhostInstance instance, string postId)
{
var request = CreateRequest(HttpMethod.Get, instance, $"posts/{postId}/?fields=id,title,slug,feature_image,html,created_at,updated_at,status");
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("posts", out var posts) && posts.GetArrayLength() > 0)
{
var post = posts[0];
return new GhostPost
{
Id = GetString(post, "id"),
Title = GetString(post, "title"),
Slug = GetString(post, "slug"),
Html = GetString(post, "html"),
FeatureImage = GetString(post, "feature_image"),
CreatedAt = GetDateTime(post, "created_at"),
UpdatedAt = GetDateTime(post, "updated_at"),
Status = GetString(post, "status")
};
}
return null;
}
public async Task<bool> UpdatePostAsync(GhostInstance instance, GhostPost post)
{
var request = CreateRequest(HttpMethod.Put, instance, $"posts/{post.Id}/?source=html");
var body = new
{
posts = new[]
{
new
{
id = post.Id,
title = post.Title,
html = post.Html,
status = post.Status,
feature_image = post.FeatureImage,
updated_at = post.UpdatedAt?.ToString("o")
}
}
};
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
public async Task<string?> UploadImageAsync(GhostInstance instance, Stream fileStream, string fileName, string? reference = null)
{
var token = JwtTokenService.GenerateToken(instance.AdminApiKey);
var request = new HttpRequestMessage(HttpMethod.Post, $"{instance.Url.TrimEnd('/')}/ghost/api/admin/images/upload/");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Ghost", token);
request.Headers.Add("Accept-Version", "v5.0");
var content = new MultipartFormDataContent();
var streamContent = new StreamContent(fileStream);
var ext = Path.GetExtension(fileName).ToLowerInvariant();
var mimeType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
_ => "application/octet-stream"
};
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mimeType);
content.Add(streamContent, "file", fileName);
if (!string.IsNullOrEmpty(reference))
content.Add(new StringContent(reference), "ref");
request.Content = content;
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
{
return GetString(images[0], "url");
}
return null;
}
private HttpRequestMessage CreateRequest(HttpMethod method, GhostInstance instance, string endpoint)
{
var token = JwtTokenService.GenerateToken(instance.AdminApiKey);
var request = new HttpRequestMessage(method, $"{instance.Url.TrimEnd('/')}/ghost/api/admin/{endpoint}");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Ghost", token);
request.Headers.Add("Accept-Version", "v5.0");
return request;
}
private static string GetString(JsonElement element, string property)
{
if (element.TryGetProperty(property, out var prop) && prop.ValueKind != JsonValueKind.Null)
return prop.GetString() ?? "";
return "";
}
private static DateTime? GetDateTime(JsonElement element, string property)
{
if (element.TryGetProperty(property, out var prop) && prop.ValueKind != JsonValueKind.Null)
if (DateTime.TryParse(prop.GetString(), out var dt))
return dt;
return null;
}
}
public class GhostImageReference
{
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("ref")] public string? Ref { get; set; }
}

View file

@ -0,0 +1,43 @@
using System.Text.Json;
using GhostImageManager.Models;
namespace GhostImageManager.Services;
public class GhostConfigService
{
private readonly string _configPath;
private readonly object _lock = new();
public GhostConfigService(IWebHostEnvironment env)
{
_configPath = Path.Combine(env.ContentRootPath, "ghost-instances.json");
}
public GhostConfig Load()
{
if (!File.Exists(_configPath))
{
var empty = new GhostConfig();
Save(empty);
return empty;
}
var json = File.ReadAllText(_configPath);
return JsonSerializer.Deserialize<GhostConfig>(json, GetOptions()) ?? new GhostConfig();
}
public void Save(GhostConfig config)
{
lock (_lock)
{
var json = JsonSerializer.Serialize(config, GetOptions());
File.WriteAllText(_configPath, json);
}
}
private static JsonSerializerOptions GetOptions() => new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}

View file

@ -0,0 +1,215 @@
using System.Text.RegularExpressions;
using GhostImageManager.Models;
using HtmlAgilityPack;
namespace GhostImageManager.Services;
public class ImageAnalyzerService
{
private readonly GhostApiService _ghostApiService;
private readonly ILogger<ImageAnalyzerService> _logger;
public ImageAnalyzerService(GhostApiService ghostApiService, ILogger<ImageAnalyzerService> logger)
{
_ghostApiService = ghostApiService;
_logger = logger;
}
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
{
var images = new List<GhostImage>();
if (!Directory.Exists(instance.ImageFolderPath))
{
_logger.LogWarning("Image folder not found: {Path}", instance.ImageFolderPath);
return images;
}
// 1. Alle Posts abrufen
var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm);
// 2. Bild-Referenzen aus Posts sammeln
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
foreach (var post in posts)
{
// Feature Image
if (!string.IsNullOrEmpty(post.FeatureImage))
{
if (!imageReferences.ContainsKey(post.FeatureImage))
imageReferences[post.FeatureImage] = new List<PostReference>();
imageReferences[post.FeatureImage].Add(new PostReference
{
PostId = post.Id,
Title = post.Title,
Slug = post.Slug,
IsFeatureImage = true,
IsInContent = false
});
}
// Bilder im HTML
if (!string.IsNullOrEmpty(post.Html))
{
var htmlImages = ExtractImageUrls(post.Html);
foreach (var imgUrl in htmlImages)
{
if (!imageReferences.ContainsKey(imgUrl))
imageReferences[imgUrl] = new List<PostReference>();
// Prüfen ob bereits als Feature Image vorhanden
var existing = imageReferences[imgUrl].FirstOrDefault(r => r.PostId == post.Id);
if (existing != null)
{
existing.IsInContent = true;
}
else
{
imageReferences[imgUrl].Add(new PostReference
{
PostId = post.Id,
Title = post.Title,
Slug = post.Slug,
IsFeatureImage = false,
IsInContent = true
});
}
}
}
}
// 3. Lokale Dateien scannen
var localFiles = ScanLocalImageFiles(instance.ImageFolderPath);
// 4. GhostImage-Objekte erstellen
foreach (var file in localFiles)
{
var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName);
var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName).Replace("\\", "/");
var image = new GhostImage
{
FilePath = file.FullName,
FileName = file.Name,
RelativePath = relativePath,
Url = absoluteUrl,
FileSize = file.Length,
LastModified = file.LastWriteTime,
UsedInPosts = imageReferences.TryGetValue(absoluteUrl, out var refs) ? refs : new List<PostReference>(),
IsOrphaned = !imageReferences.ContainsKey(absoluteUrl)
};
// Dimensionen ermitteln
GetImageDimensions(image);
images.Add(image);
}
// Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder)
foreach (var url in imageReferences.Keys)
{
var localPath = ConvertUrlToLocalPath(instance, url);
if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)))
{
images.Add(new GhostImage
{
Url = url,
FileName = Path.GetFileName(url),
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/"),
FilePath = localPath,
UsedInPosts = imageReferences[url],
IsOrphaned = false
});
}
}
return images;
}
private List<FileInfo> ScanLocalImageFiles(string folderPath)
{
var files = new List<FileInfo>();
var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" };
if (!Directory.Exists(folderPath))
return files;
foreach (var ext in extensions)
{
files.AddRange(new DirectoryInfo(folderPath)
.EnumerateFiles($"*{ext}", SearchOption.AllDirectories));
}
return files.OrderBy(f => f.FullName).ToList();
}
private List<string> ExtractImageUrls(string html)
{
var urls = new List<string>();
try
{
var doc = new HtmlDocument();
doc.LoadHtml(html);
var imgTags = doc.DocumentNode.SelectNodes("//img[@src]");
if (imgTags != null)
{
foreach (var img in imgTags)
{
var src = img.GetAttributeValue("src", "").Trim();
if (!string.IsNullOrEmpty(src))
urls.Add(src);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Parsen von HTML");
}
return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath)
{
var relativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/");
return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}";
}
private string ConvertUrlToLocalPath(GhostInstance instance, string url)
{
var contentImagesUrl = $"{instance.Url.TrimEnd('/')}/content/images/";
if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase))
{
var relativePath = url[contentImagesUrl.Length..];
return Path.Combine(instance.ImageFolderPath, relativePath.Replace("/", "\\"));
}
return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url));
}
private string GetRelativePath(string basePath, string targetPath)
{
var baseUri = new Uri(basePath.EndsWith("\\") || basePath.EndsWith("/") ? basePath : basePath + "\\");
var targetUri = new Uri(targetPath);
var relativeUri = baseUri.MakeRelativeUri(targetUri);
return Uri.UnescapeDataString(relativeUri.ToString());
}
private void GetImageDimensions(GhostImage image)
{
if (image.FilePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
return;
try
{
using var fs = File.OpenRead(image.FilePath);
var (width, height) = ImageDimensionReader.Read(fs);
image.Width = width;
image.Height = height;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath);
}
}
}

View file

@ -0,0 +1,61 @@
using GhostImageManager.Models;
namespace GhostImageManager.Services;
public class ImageDeletionService
{
private readonly ILogger<ImageDeletionService> _logger;
public ImageDeletionService(ILogger<ImageDeletionService> logger)
{
_logger = logger;
}
public Task<bool> DeleteImageAsync(GhostImage image)
{
return Task.FromResult(DeleteFile(image.FilePath));
}
public Task<(int Success, int Failed)> DeleteImagesAsync(IEnumerable<GhostImage> images)
{
int success = 0;
int failed = 0;
foreach (var image in images)
{
if (DeleteFile(image.FilePath))
success++;
else
failed++;
}
return Task.FromResult((success, failed));
}
private bool DeleteFile(string filePath)
{
try
{
if (!File.Exists(filePath))
{
_logger.LogWarning("Datei nicht gefunden: {Path}", filePath);
return false;
}
File.Delete(filePath);
// Auch Backup löschen, falls vorhanden
var backupPath = filePath + ".backup";
if (File.Exists(backupPath))
File.Delete(backupPath);
_logger.LogInformation("Datei gelöscht: {Path}", filePath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Löschen von {Path}", filePath);
return false;
}
}
}

View file

@ -0,0 +1,123 @@
namespace GhostImageManager.Services;
public static class ImageDimensionReader
{
public static (int Width, int Height) Read(Stream stream)
{
using var reader = new BinaryReader(stream);
var header = reader.ReadBytes(8);
if (IsJpeg(header))
return ReadJpeg(stream, reader);
if (IsPng(header))
return ReadPng(reader);
if (IsGif(header))
return ReadGif(reader);
if (IsWebP(header))
return ReadWebP(stream, reader);
return (0, 0);
}
private static bool IsJpeg(byte[] header) => header[0] == 0xFF && header[1] == 0xD8;
private static bool IsPng(byte[] header) => header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47;
private static bool IsGif(byte[] header) => header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46;
private static bool IsWebP(byte[] header) => header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46;
private static (int, int) ReadPng(BinaryReader reader)
{
reader.BaseStream.Seek(16, SeekOrigin.Begin);
var width = ReadBigEndianInt(reader);
var height = ReadBigEndianInt(reader);
return (width, height);
}
private static (int, int) ReadGif(BinaryReader reader)
{
reader.BaseStream.Seek(6, SeekOrigin.Begin);
var width = reader.ReadUInt16();
var height = reader.ReadUInt16();
return (width, height);
}
private static (int, int) ReadJpeg(Stream stream, BinaryReader reader)
{
stream.Seek(2, SeekOrigin.Begin);
while (stream.Position < stream.Length)
{
int marker = reader.ReadByte();
if (marker != 0xFF) continue;
int type = reader.ReadByte();
if (type == 0xD9 || type == 0xDA) break; // EOI or SOS
// Skip padding
while (type == 0xFF) type = reader.ReadByte();
if (type == 0xD9 || type == 0xDA) break;
if (type == 0xD8 || type == 0x01) continue;
int length = (reader.ReadByte() << 8) | reader.ReadByte();
if (length < 2) break;
if (type == 0xC0 || type == 0xC1 || type == 0xC2 || type == 0xC3 ||
type == 0xC5 || type == 0xC6 || type == 0xC7 ||
type == 0xC9 || type == 0xCA || type == 0xCB ||
type == 0xCD || type == 0xCE || type == 0xCF)
{
reader.ReadByte(); // precision
int height = (reader.ReadByte() << 8) | reader.ReadByte();
int width = (reader.ReadByte() << 8) | reader.ReadByte();
return (width, height);
}
stream.Seek(length - 2, SeekOrigin.Current);
}
return (0, 0);
}
private static (int, int) ReadWebP(Stream stream, BinaryReader reader)
{
stream.Seek(12, SeekOrigin.Begin); // Skip RIFF....WEBP
var chunk = reader.ReadChars(4);
var chunkType = new string(chunk);
if (chunkType == "VP8 ")
{
stream.Seek(26, SeekOrigin.Begin);
int width = reader.ReadUInt16() & 0x3FFF;
int height = reader.ReadUInt16() & 0x3FFF;
return (width, height);
}
if (chunkType == "VP8L")
{
stream.Seek(21, SeekOrigin.Begin);
int data = reader.ReadInt32();
int width = (data & 0x3FFF) + 1;
int height = ((data >> 14) & 0x3FFF) + 1;
return (width, height);
}
if (chunkType == "VP8X")
{
stream.Seek(24, SeekOrigin.Begin);
var sizeBytes = reader.ReadBytes(6);
int width = (sizeBytes[0] | (sizeBytes[1] << 8) | (sizeBytes[2] << 16)) + 1;
int height = (sizeBytes[3] | (sizeBytes[4] << 8) | (sizeBytes[5] << 16)) + 1;
return (width, height);
}
return (0, 0);
}
private static int ReadBigEndianInt(BinaryReader reader)
{
var bytes = reader.ReadBytes(4);
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
}
}

View file

@ -0,0 +1,86 @@
using System.IO.Compression;
using System.Text;
using GhostImageManager.Models;
namespace GhostImageManager.Services;
public class ImageExportService
{
public byte[] ExportToCsv(List<GhostImage> images)
{
var sb = new StringBuilder();
sb.AppendLine("Dateiname;Relativer Pfad;URL;Größe Bytes;Größe;Dimensionen;Letzte Änderung;Verwaist;Verwendet in Posts");
foreach (var image in images)
{
var posts = string.Join(" | ", image.UsedInPosts.Select(p => $"{p.Title} ({(p.IsFeatureImage ? "Feature" : "Content")})"));
sb.AppendLine($"{EscapeCsv(image.FileName)};{EscapeCsv(image.RelativePath)};{EscapeCsv(image.Url)};{image.FileSize};{image.FormattedFileSize};{image.Dimensions};{image.LastModified:yyyy-MM-dd HH:mm};{image.IsOrphaned};{EscapeCsv(posts)}");
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
public byte[] ExportToTxt(List<GhostImage> images)
{
var sb = new StringBuilder();
sb.AppendLine("Ghost Image Export");
sb.AppendLine($"Erstellt am: {DateTime.Now:yyyy-MM-dd HH:mm}");
sb.AppendLine($"Anzahl Bilder: {images.Count}");
sb.AppendLine($"Verwaiste Bilder: {images.Count(i => i.IsOrphaned)}");
sb.AppendLine(new string('-', 80));
foreach (var image in images)
{
sb.AppendLine($"Datei: {image.FileName}");
sb.AppendLine($"URL: {image.Url}");
sb.AppendLine($"Pfad: {image.RelativePath}");
sb.AppendLine($"Verwaist: {(image.IsOrphaned ? "Ja" : "Nein")}");
if (image.UsedInPosts.Any())
{
sb.AppendLine("Verwendet in:");
foreach (var post in image.UsedInPosts)
{
sb.AppendLine($" - {post.Title} ({(post.IsFeatureImage ? "Feature" : "Content")})");
}
}
sb.AppendLine();
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
public async Task<byte[]> ExportToZipAsync(List<GhostImage> images)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var image in images)
{
if (!File.Exists(image.FilePath))
continue;
var entry = archive.CreateEntry(image.RelativePath.Replace("/", "\\"));
await using (var entryStream = entry.Open())
{
await using var fileStream = File.OpenRead(image.FilePath);
await fileStream.CopyToAsync(entryStream);
}
}
}
return ms.ToArray();
}
private static string EscapeCsv(string value)
{
if (string.IsNullOrEmpty(value))
return "";
if (value.Contains(';') || value.Contains('"') || value.Contains('\n'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
}

View file

@ -0,0 +1,61 @@
using GhostImageManager.Models;
namespace GhostImageManager.Services;
public class ImageReplacementService
{
private readonly ILogger<ImageReplacementService> _logger;
public ImageReplacementService(ILogger<ImageReplacementService> logger)
{
_logger = logger;
}
public async Task<bool> ReplaceImageAsync(GhostImage oldImage, Stream newImageStream, string newFileName)
{
try
{
if (!File.Exists(oldImage.FilePath))
{
_logger.LogError("Alte Bilddatei nicht gefunden: {Path}", oldImage.FilePath);
return false;
}
// Backup der alten Datei erstellen
var backupPath = oldImage.FilePath + ".backup";
File.Copy(oldImage.FilePath, backupPath, overwrite: true);
// Neue Datei schreiben
using (var fileStream = File.Create(oldImage.FilePath))
{
await newImageStream.CopyToAsync(fileStream);
}
_logger.LogInformation("Bild ersetzt: {OldFile} durch {NewFile}", oldImage.FileName, newFileName);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Ersetzen des Bildes {File}", oldImage.FileName);
return false;
}
}
public bool RestoreBackup(GhostImage image)
{
try
{
var backupPath = image.FilePath + ".backup";
if (!File.Exists(backupPath))
return false;
File.Copy(backupPath, image.FilePath, overwrite: true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Wiederherstellen des Backups für {File}", image.FileName);
return false;
}
}
}

View file

@ -0,0 +1,34 @@
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace GhostImageManager.Services;
public static class JwtTokenService
{
public static string GenerateToken(string adminApiKey)
{
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 securityKey = new SymmetricSecurityKey(Convert.FromHexString(secret));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
header: new JwtHeader(credentials) { { "kid", keyId } },
payload: new JwtPayload
{
{ "iat", now.ToUnixTimeSeconds() },
{ "exp", now.AddMinutes(5).ToUnixTimeSeconds() },
{ "aud", "/admin/" }
}
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AppSettings": {
"Password": "change-me-in-production",
"SessionTimeoutMinutes": 480
}
}

View file

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.12",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View file

@ -0,0 +1,12 @@
{
"instances": [
{
"id": "prod",
"name": "Produktion",
"url": "https://www.brigitteundrolandunterwegs.com",
"adminApiKey": "HIER_ADMIN_API_KEY_EINGEBEN",
"contentApiKey": "",
"imageFolderPath": "C:\\Ghost\\content\\images"
}
]
}

BIN
GhostImageManager/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
GhostImageManager/start.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View file

@ -0,0 +1,179 @@
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
/* Login Page Styles */
.login-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
align-items: flex-end;
justify-content: center;
}
.login-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.login-background-placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.login-form-wrapper {
position: relative;
z-index: 1;
width: 100%;
display: flex;
justify-content: center;
padding-bottom: 3rem;
}
.login-form-card {
background-color: rgba(33, 37, 41, 0.75);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-form-card .form-control {
background-color: rgba(255, 255, 255, 0.9);
border: none;
}
.login-form-card .form-control:focus {
background-color: #fff;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.5);
}
/* Custom Ghost Image Manager Styles */
.page {
display: flex;
flex-direction: row;
}
.sidebar {
width: 250px;
min-height: 100vh;
background-color: #212529;
color: white;
}
main {
flex: 1;
min-width: 0;
}
.top-row {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.content {
padding-top: 1.5rem;
}
.card-img-top {
overflow: hidden;
}
.card-img-top img {
transition: transform 0.2s;
}
.card-img-top:hover img {
transform: scale(1.05);
}
.modal.show {
display: block;
}
.nav-scrollable {
display: block;
}
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem;
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.1);
}
@media (max-width: 768px) {
.page {
flex-direction: column;
}
.sidebar {
width: 100%;
min-height: auto;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

View file

@ -175,7 +175,7 @@ function rbr_get_post_details_with_media($data) {
// Duplikate entfernen // Duplikate entfernen
$unique_media = array_values(array_unique($media_urls)); $unique_media = array_values(array_unique($media_urls));
// 4. Kategorien ( Categories Only, keine Tags) // 4. Kategorien
$categories = array(); $categories = array();
$cats = get_the_category($post_id); $cats = get_the_category($post_id);
if (!empty($cats) && !is_wp_error($cats)) { if (!empty($cats) && !is_wp_error($cats)) {
@ -188,23 +188,44 @@ function rbr_get_post_details_with_media($data) {
} }
} }
// 5. Keywords (aus Yoast SEO oder Rank Math oder Custom Field) // 5. Tags (post_tag Taxonomy)
$tags = array();
$post_tags = get_the_tags($post_id);
if (!empty($post_tags) && !is_wp_error($post_tags)) {
foreach ($post_tags as $tag) {
$tags[] = array(
'id' => intval($tag->term_id),
'name' => $tag->name,
'slug' => $tag->slug,
);
}
}
// 6. Keywords (aus Yoast SEO oder Rank Math oder Custom Field)
$keywords = ''; $keywords = '';
// Yoast SEO // Yoast SEO: Prüfe auf Meta-Key direkt (funktioniert in allen Versionen)
if (function_exists('yoast_get_primary_term')) { $yoast_kw = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
$keywords = get_post_meta($post_id, '_yoast_wpseo_focuskw', true); if (!empty($yoast_kw)) {
$keywords = $yoast_kw;
} }
// Rank Math // Rank Math
if (empty($keywords) && function_exists('rank_math_get_single_post_data')) { if (empty($keywords) && function_exists('rank_math_get_single_post_data')) {
$rank_data = rank_math_get_single_post_data($post_id); $rank_data = rank_math_get_single_post_data($post_id);
if (!empty($rank_data->get_keywords())) { if (method_exists($rank_data, 'get_keywords')) {
$keywords = $rank_data->get_keywords(); $kw = $rank_data->get_keywords();
if (!empty($kw)) {
$keywords = $kw;
}
} }
} }
// Fallback: Custom Field 'post_keywords' // Fallback: Custom Field 'post_keywords'
if (empty($keywords)) { if (empty($keywords)) {
$keywords = get_post_meta($post_id, 'post_keywords', true); $keywords = get_post_meta($post_id, 'post_keywords', true);
} }
// Fallback: Tags als Keywords
if (empty($keywords) && !empty($tags)) {
$keywords = implode(', ', array_column($tags, 'name'));
}
// Fallback: Kategorien als Keywords // Fallback: Kategorien als Keywords
if (empty($keywords) && !empty($categories)) { if (empty($keywords) && !empty($categories)) {
$keywords = implode(', ', array_column($categories, 'name')); $keywords = implode(', ', array_column($categories, 'name'));
@ -241,6 +262,7 @@ function rbr_get_post_details_with_media($data) {
), ),
'featured_image' => $featured_image_url, 'featured_image' => $featured_image_url,
'categories' => $categories, 'categories' => $categories,
'tags' => $tags,
'keywords' => $keywords, 'keywords' => $keywords,
'comments' => $comments, 'comments' => $comments,
'media_files' => $unique_media, 'media_files' => $unique_media,