Compare commits
2 commits
d9df4947f4
...
6e558d90d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e558d90d0 | |||
| b598b0d5af |
52 changed files with 3227 additions and 26 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,8 @@
|
|||
/CSharpGhostImporter/.vs/*
|
||||
/CSharpGhostImporter/bin/*
|
||||
/CSharpGhostImporter/obj/*
|
||||
/0
|
||||
/GhostImageManager/.vs/*
|
||||
/GhostImageManager/bin/*
|
||||
/GhostImageManager/obj/*
|
||||
/GhostImageManager/Properties/*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using HtmlAgilityPack;
|
||||
using wp2ghost;
|
||||
|
||||
class Program
|
||||
|
|
@ -22,6 +25,7 @@ class Program
|
|||
ImageManager.Init(BaseDir);
|
||||
PostFetcher.Init(BaseDir);
|
||||
GhostExporter.Init(BaseDir);
|
||||
LinkDecisionManager.Init(BaseDir);
|
||||
GhostApi.Init(GhostUrl, GhostAdminApiKey, GhostContentApiKey, ImageManager.ImageContainerPath);
|
||||
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
|
|
@ -57,8 +61,12 @@ class Program
|
|||
case "7":
|
||||
await GhostMenuAsync();
|
||||
break;
|
||||
case "8":
|
||||
LinkDecisionManager.ShowInfo();
|
||||
break;
|
||||
case "0":
|
||||
ImageManager.SaveCache();
|
||||
LinkDecisionManager.Save();
|
||||
running = false;
|
||||
Console.WriteLine("Programm beendet. Auf Wiedersehen!");
|
||||
break;
|
||||
|
|
@ -89,6 +97,7 @@ class Program
|
|||
Console.WriteLine(" 5 - Abgefragte Objekte zurücksetzen");
|
||||
Console.WriteLine(" 6 - Bilder lokal löschen");
|
||||
Console.WriteLine(" 7 - Beiträge direkt an Ghost senden");
|
||||
Console.WriteLine(" 8 - Gespeicherte Link-Entscheidungen anzeigen");
|
||||
Console.WriteLine(" 0 - Beenden");
|
||||
Console.WriteLine("-----------------------------------------");
|
||||
Console.Write("Auswahl: ");
|
||||
|
|
@ -352,6 +361,9 @@ class Program
|
|||
Console.Write("HTML-Inhalt: ");
|
||||
string? html = Console.ReadLine()?.Trim();
|
||||
|
||||
// Links validieren und ggf. korrigieren
|
||||
html = await ValidateAndFixLinksAsync(html);
|
||||
|
||||
Console.Write("Status (published/draft) [published]: ");
|
||||
string? status = Console.ReadLine()?.Trim();
|
||||
if (string.IsNullOrEmpty(status)) status = "published";
|
||||
|
|
@ -459,6 +471,233 @@ class Program
|
|||
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()
|
||||
{
|
||||
string ghostPostsDir = Path.Combine(BaseDir, "ghostPosts");
|
||||
|
|
@ -509,12 +748,14 @@ class Program
|
|||
Console.WriteLine($" Post: {postFile.Title}");
|
||||
Console.WriteLine($" Slug: {postFile.Slug}");
|
||||
Console.WriteLine($" Kategorien: {postFile.Categories.Count}");
|
||||
Console.WriteLine($" Tags: {postFile.Tags.Count}");
|
||||
Console.WriteLine($" Kommentare: {postFile.Comments.Count}");
|
||||
Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}");
|
||||
|
||||
// 1. ALLE Bilder hochladen + Ghost-URL-Mapping erstellen
|
||||
var ghostUrlMap = new Dictionary<string, string>();
|
||||
string html = postFile.Html ?? "";
|
||||
html = await ValidateAndFixLinksAsync(html);
|
||||
|
||||
if (postFile.ImageFiles.Count > 0)
|
||||
{
|
||||
|
|
@ -572,12 +813,47 @@ class Program
|
|||
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>();
|
||||
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...");
|
||||
tags = await GhostApi.EnsureTagsAsync(postFile.Categories);
|
||||
Console.WriteLine();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ namespace wp2ghost
|
|||
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; }
|
||||
[JsonPropertyName("feature_image")] public string? FeatureImage { get; set; }
|
||||
[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("comments")] public List<WpComment> Comments { get; set; } = new();
|
||||
[JsonPropertyName("image_files")] public List<string> ImageFiles { get; set; } = new();
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ namespace wp2ghost
|
|||
ConvertSingleImages(htmlDoc);
|
||||
ConvertFigures(htmlDoc);
|
||||
ConvertWpClasses(htmlDoc);
|
||||
AddTargetBlankToExternalLinks(htmlDoc);
|
||||
|
||||
return htmlDoc.DocumentNode.OuterHtml;
|
||||
}
|
||||
|
|
@ -134,11 +135,27 @@ namespace wp2ghost
|
|||
container.AddClass("kg-gallery-container");
|
||||
|
||||
var wrappers = new List<HtmlNode>();
|
||||
var imageCaptions = new List<string>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var img = item.SelectSingleNode(".//img");
|
||||
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);
|
||||
if (string.IsNullOrEmpty(src)) continue;
|
||||
|
||||
|
|
@ -150,11 +167,14 @@ namespace wp2ghost
|
|||
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||
ghostGallery.AppendChild(container);
|
||||
|
||||
var captionNode = gallery.SelectSingleNode(".//*[contains(@class, 'gallery-caption')] | .//figcaption");
|
||||
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText))
|
||||
// Gallery-Bild-Titel übertragen, wenn vorhanden
|
||||
if (imageCaptions.Count > 0)
|
||||
{
|
||||
// Ersten nicht-leeren Titel nehmen als Gallery-Kapitel
|
||||
string firstCaption = imageCaptions[0];
|
||||
|
||||
var newCaption = htmlDoc.CreateElement("figcaption");
|
||||
newCaption.InnerHtml = captionNode.InnerHtml;
|
||||
newCaption.InnerHtml = firstCaption;
|
||||
ghostGallery.AppendChild(newCaption);
|
||||
ghostGallery.AddClass("kg-card-hascaption");
|
||||
}
|
||||
|
|
@ -207,15 +227,24 @@ namespace wp2ghost
|
|||
AppendGalleryRows(htmlDoc, container, wrappers, GetGalleryColumns(gallery));
|
||||
ghostGallery.AppendChild(container);
|
||||
|
||||
// Caption aus erstem figcaption im Gallery-Container extrahieren
|
||||
var captionNode = gallery.SelectSingleNode(".//figcaption");
|
||||
if (captionNode != null && !string.IsNullOrWhiteSpace(captionNode.InnerText))
|
||||
// Alle individuellen Bildunterschriften aus der Gutenberg-Gallery sammeln
|
||||
var captionNodes = gallery.SelectNodes(".//figcaption");
|
||||
if (captionNodes != null && captionNodes.Count > 0)
|
||||
{
|
||||
var distinctCaptions = captionNodes
|
||||
.Select(n => n.InnerText.Trim())
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (distinctCaptions.Count > 0)
|
||||
{
|
||||
var newCaption = htmlDoc.CreateElement("figcaption");
|
||||
newCaption.InnerHtml = captionNode.InnerHtml;
|
||||
newCaption.InnerHtml = string.Join(" · ", distinctCaptions);
|
||||
ghostGallery.AppendChild(newCaption);
|
||||
ghostGallery.AddClass("kg-card-hascaption");
|
||||
}
|
||||
}
|
||||
|
||||
// data-carousel-extra als Ghost-Attribut übertragen (falls vorhanden)
|
||||
if (!string.IsNullOrEmpty(carouselExtra))
|
||||
|
|
@ -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)
|
||||
{
|
||||
string classes = node.GetAttributeValue("class", "");
|
||||
|
|
|
|||
|
|
@ -20,11 +20,28 @@ namespace wp2ghost
|
|||
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
|
||||
public static bool TryGetRelativePath(string url, out string relativePath)
|
||||
{
|
||||
// 1. Direkte URL-Prüfung im Cache
|
||||
if (Cache.TryGetValue(url, out var cached))
|
||||
{
|
||||
relativePath = cached.RelativePath;
|
||||
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 = "";
|
||||
return false;
|
||||
}
|
||||
|
|
@ -46,7 +63,10 @@ namespace wp2ghost
|
|||
{
|
||||
var lines = new List<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -223,8 +243,13 @@ namespace wp2ghost
|
|||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length == 3)
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
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)
|
||||
|
|
|
|||
138
CSharpGhostImporter/LinkDecisionManager.cs
Normal file
138
CSharpGhostImporter/LinkDecisionManager.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,9 @@ namespace wp2ghost
|
|||
return;
|
||||
}
|
||||
|
||||
ProcessedPosts.Clear();
|
||||
LoadProcessedPosts();
|
||||
|
||||
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}");
|
||||
|
||||
|
|
@ -105,11 +108,15 @@ namespace wp2ghost
|
|||
Console.WriteLine($"[{++processed}/{pendingIds.Count}] -> Beitrag/Seite ID: {wpId}...");
|
||||
string detailUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/custom/v1/post-details/{wpId}";
|
||||
|
||||
//string postUrl = $"{wpBaseUrl.TrimEnd('/')}/wp-json/wp/v2/posts/{wpId}?_embed";
|
||||
|
||||
try
|
||||
{
|
||||
var detailJson = await httpClient.GetStringAsync(detailUrl);
|
||||
var wpPost = JsonSerializer.Deserialize<WpPostResponse>(detailJson);
|
||||
|
||||
//var postJson = await httpClient.GetStringAsync(postUrl);
|
||||
|
||||
if (wpPost == null)
|
||||
{
|
||||
Console.WriteLine($" Übersprungen (null).");
|
||||
|
|
@ -217,7 +224,7 @@ namespace wp2ghost
|
|||
GhostPosts.Add(ghostPost);
|
||||
|
||||
// JSON-Datei in ghostPosts/ speichern
|
||||
var postFile = new GhostPostFile
|
||||
var ghostPostFile = new GhostPostFile
|
||||
{
|
||||
WpId = wpId,
|
||||
WpSlug = wpPost.Slug,
|
||||
|
|
@ -230,13 +237,17 @@ namespace wp2ghost
|
|||
PublishedAt = postDate.ToString("o"),
|
||||
FeatureImage = featureImageGhostPath,
|
||||
Categories = wpPost.Categories ?? new(),
|
||||
Tags = wpPost.Tags ?? new(),
|
||||
Keywords = wpPost.Keywords ?? "",
|
||||
Comments = wpPost.Comments ?? new(),
|
||||
ImageFiles = imageFiles,
|
||||
Html = htmlContent
|
||||
};
|
||||
string jsonPath = Path.Combine(GhostPostsDir, $"{postFile.WpId}.json");
|
||||
File.WriteAllText(jsonPath, JsonSerializer.Serialize(postFile, new JsonSerializerOptions { WriteIndented = true }));
|
||||
string jsonPath = Path.Combine(GhostPostsDir, $"{ghostPostFile.WpId}.json");
|
||||
File.WriteAllText(jsonPath, JsonSerializer.Serialize(ghostPostFile, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
// Cache sofort speichern (nicht erst beim Beenden)
|
||||
ImageManager.SaveCache();
|
||||
|
||||
MarkProcessed(wpId, wpPost.Slug);
|
||||
internalId++;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ namespace wp2ghost
|
|||
[JsonPropertyName("excerpt")] public string? Excerpt { get; set; }
|
||||
[JsonPropertyName("featured_image")] public string? FeaturedImage { get; set; }
|
||||
[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("comments")] public List<WpComment>? Comments { get; set; } = new();
|
||||
[JsonPropertyName("media_files")] public List<string>? MediaFiles { get; set; } = new();
|
||||
|
|
|
|||
34
GhostImageManager/Components/App.razor
Normal file
34
GhostImageManager/Components/App.razor
Normal 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>
|
||||
9
GhostImageManager/Components/Layout/EmptyLayout.razor
Normal file
9
GhostImageManager/Components/Layout/EmptyLayout.razor
Normal 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>
|
||||
23
GhostImageManager/Components/Layout/MainLayout.razor
Normal file
23
GhostImageManager/Components/Layout/MainLayout.razor
Normal 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>
|
||||
20
GhostImageManager/Components/Layout/MainLayout.razor.css
Normal file
20
GhostImageManager/Components/Layout/MainLayout.razor.css
Normal 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;
|
||||
}
|
||||
45
GhostImageManager/Components/Layout/NavMenu.razor
Normal file
45
GhostImageManager/Components/Layout/NavMenu.razor
Normal 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>
|
||||
31
GhostImageManager/Components/Layout/ReconnectModal.razor
Normal file
31
GhostImageManager/Components/Layout/ReconnectModal.razor
Normal 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>
|
||||
157
GhostImageManager/Components/Layout/ReconnectModal.razor.css
Normal file
157
GhostImageManager/Components/Layout/ReconnectModal.razor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
63
GhostImageManager/Components/Layout/ReconnectModal.razor.js
Normal file
63
GhostImageManager/Components/Layout/ReconnectModal.razor.js
Normal 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();
|
||||
}
|
||||
}
|
||||
9
GhostImageManager/Components/Pages/AccessDenied.razor
Normal file
9
GhostImageManager/Components/Pages/AccessDenied.razor
Normal 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>
|
||||
36
GhostImageManager/Components/Pages/Error.razor
Normal file
36
GhostImageManager/Components/Pages/Error.razor
Normal 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;
|
||||
}
|
||||
55
GhostImageManager/Components/Pages/Home.razor
Normal file
55
GhostImageManager/Components/Pages/Home.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
625
GhostImageManager/Components/Pages/Images.razor
Normal file
625
GhostImageManager/Components/Pages/Images.razor
Normal 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
|
||||
}
|
||||
}
|
||||
59
GhostImageManager/Components/Pages/Login.razor
Normal file
59
GhostImageManager/Components/Pages/Login.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
10
GhostImageManager/Components/Pages/Logout.razor
Normal file
10
GhostImageManager/Components/Pages/Logout.razor
Normal 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>
|
||||
5
GhostImageManager/Components/Pages/NotFound.razor
Normal file
5
GhostImageManager/Components/Pages/NotFound.razor
Normal 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>
|
||||
192
GhostImageManager/Components/Pages/Settings.razor
Normal file
192
GhostImageManager/Components/Pages/Settings.razor
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
6
GhostImageManager/Components/Routes.razor
Normal file
6
GhostImageManager/Components/Routes.razor
Normal 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>
|
||||
15
GhostImageManager/Components/_Imports.razor
Normal file
15
GhostImageManager/Components/_Imports.razor
Normal 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
|
||||
30
GhostImageManager/GhostImageManager.csproj
Normal file
30
GhostImageManager/GhostImageManager.csproj
Normal 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>
|
||||
7
GhostImageManager/GhostImageManager.csproj.user
Normal file
7
GhostImageManager/GhostImageManager.csproj.user
Normal 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>
|
||||
7
GhostImageManager/Models/AppSettings.cs
Normal file
7
GhostImageManager/Models/AppSettings.cs
Normal 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;
|
||||
}
|
||||
6
GhostImageManager/Models/GhostConfig.cs
Normal file
6
GhostImageManager/Models/GhostConfig.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace GhostImageManager.Models;
|
||||
|
||||
public class GhostConfig
|
||||
{
|
||||
public List<GhostInstance> Instances { get; set; } = new();
|
||||
}
|
||||
33
GhostImageManager/Models/GhostImage.cs
Normal file
33
GhostImageManager/Models/GhostImage.cs
Normal 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; }
|
||||
}
|
||||
11
GhostImageManager/Models/GhostInstance.cs
Normal file
11
GhostImageManager/Models/GhostInstance.cs
Normal 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; } = "";
|
||||
}
|
||||
13
GhostImageManager/Models/GhostPost.cs
Normal file
13
GhostImageManager/Models/GhostPost.cs
Normal 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; } = "";
|
||||
}
|
||||
102
GhostImageManager/Program.cs
Normal file
102
GhostImageManager/Program.cs
Normal 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();
|
||||
51
GhostImageManager/Services/AuthService.cs
Normal file
51
GhostImageManager/Services/AuthService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
169
GhostImageManager/Services/GhostApiService.cs
Normal file
169
GhostImageManager/Services/GhostApiService.cs
Normal 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; }
|
||||
}
|
||||
43
GhostImageManager/Services/GhostConfigService.cs
Normal file
43
GhostImageManager/Services/GhostConfigService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
215
GhostImageManager/Services/ImageAnalyzerService.cs
Normal file
215
GhostImageManager/Services/ImageAnalyzerService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
GhostImageManager/Services/ImageDeletionService.cs
Normal file
61
GhostImageManager/Services/ImageDeletionService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
GhostImageManager/Services/ImageDimensionReader.cs
Normal file
123
GhostImageManager/Services/ImageDimensionReader.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
86
GhostImageManager/Services/ImageExportService.cs
Normal file
86
GhostImageManager/Services/ImageExportService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
61
GhostImageManager/Services/ImageReplacementService.cs
Normal file
61
GhostImageManager/Services/ImageReplacementService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
GhostImageManager/Services/JwtTokenService.cs
Normal file
34
GhostImageManager/Services/JwtTokenService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
8
GhostImageManager/appsettings.Development.json
Normal file
8
GhostImageManager/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
GhostImageManager/appsettings.json
Normal file
13
GhostImageManager/appsettings.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AppSettings": {
|
||||
"Password": "change-me-in-production",
|
||||
"SessionTimeoutMinutes": 480
|
||||
}
|
||||
}
|
||||
13
GhostImageManager/dotnet-tools.json
Normal file
13
GhostImageManager/dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.12",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
12
GhostImageManager/ghost-instances.json
Normal file
12
GhostImageManager/ghost-instances.json
Normal 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
BIN
GhostImageManager/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
GhostImageManager/start.jpg
Normal file
BIN
GhostImageManager/start.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
179
GhostImageManager/wwwroot/app.css
Normal file
179
GhostImageManager/wwwroot/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
GhostImageManager/wwwroot/favicon.png
Normal file
BIN
GhostImageManager/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 720 KiB |
|
|
@ -175,7 +175,7 @@ function rbr_get_post_details_with_media($data) {
|
|||
// Duplikate entfernen
|
||||
$unique_media = array_values(array_unique($media_urls));
|
||||
|
||||
// 4. Kategorien ( Categories Only, keine Tags)
|
||||
// 4. Kategorien
|
||||
$categories = array();
|
||||
$cats = get_the_category($post_id);
|
||||
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 = '';
|
||||
// Yoast SEO
|
||||
if (function_exists('yoast_get_primary_term')) {
|
||||
$keywords = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
|
||||
// Yoast SEO: Prüfe auf Meta-Key direkt (funktioniert in allen Versionen)
|
||||
$yoast_kw = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
|
||||
if (!empty($yoast_kw)) {
|
||||
$keywords = $yoast_kw;
|
||||
}
|
||||
// Rank Math
|
||||
if (empty($keywords) && function_exists('rank_math_get_single_post_data')) {
|
||||
$rank_data = rank_math_get_single_post_data($post_id);
|
||||
if (!empty($rank_data->get_keywords())) {
|
||||
$keywords = $rank_data->get_keywords();
|
||||
if (method_exists($rank_data, 'get_keywords')) {
|
||||
$kw = $rank_data->get_keywords();
|
||||
if (!empty($kw)) {
|
||||
$keywords = $kw;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: Custom Field 'post_keywords'
|
||||
if (empty($keywords)) {
|
||||
$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
|
||||
if (empty($keywords) && !empty($categories)) {
|
||||
$keywords = implode(', ', array_column($categories, 'name'));
|
||||
|
|
@ -241,6 +262,7 @@ function rbr_get_post_details_with_media($data) {
|
|||
),
|
||||
'featured_image' => $featured_image_url,
|
||||
'categories' => $categories,
|
||||
'tags' => $tags,
|
||||
'keywords' => $keywords,
|
||||
'comments' => $comments,
|
||||
'media_files' => $unique_media,
|
||||
|
|
|
|||
Loading…
Reference in a new issue