Importer: Kategorien als Tags, Keywords als Meta, Bilder vollständig nach Ghost

Importer
- Kategorien werden als Ghost-Tags angelegt; bei mehreren wird die primäre
  abgefragt, die übrigen folgen als sekundäre Tags
- Schlagwörter werden ohne Nachfrage als <meta name="keywords"> in den
  Code-Injection-Header geschrieben (Ghost kennt kein meta_keywords-Feld)
- Tag-Suche lädt alle Seiten, HTML-kodierte Namen werden dekodiert

Bilder
- Varianten eines WordPress-Bildes (Original, -scaled, -WxH, ?w=) werden
  zusammengefasst, einmal geladen und im HTML alle umgeschrieben
- src bei Einzelbildern setzen, srcset/sizes entfernen
- Cache-Schlüssel ist der Pfad statt nur der Dateiname; Dateinamen eindeutig
- ZIP-Export enthält Cache-Bilder und feature_image
- Warnung bei Bild-URLs, die weiter auf WordPress zeigen
- Maximale Bildgröße 2048 px

Plugin / GhostImageManager
- Plugin liefert primäre Kategorie
- Kategorie- und Keyword-Felder samt Filter im Image Manager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-09 12:26:18 +02:00
parent a730639c2a
commit 24d88e224b
10 changed files with 313 additions and 379 deletions

View file

@ -748,7 +748,7 @@ class Program
Console.WriteLine($" Post: {postFile.Title}"); Console.WriteLine($" Post: {postFile.Title}");
Console.WriteLine($" Slug: {postFile.Slug}"); Console.WriteLine($" Slug: {postFile.Slug}");
Console.WriteLine($" Kategorien: {postFile.Categories.Count}"); Console.WriteLine($" Kategorien: {postFile.Categories.Count}");
Console.WriteLine($" Tags: {postFile.Tags.Count}"); Console.WriteLine($" Schlagwörter: {postFile.Tags.Count}");
Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); Console.WriteLine($" Kommentare: {postFile.Comments.Count}");
Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}");
@ -813,7 +813,13 @@ class Program
Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML"); Console.WriteLine($" [Warnung] {remaining} unrestrierte __GHOST_URL__ Platzhalter im HTML");
} }
// 4. Tags erstellen/zuordnen (Kategorien + Tags) // Bilder, die noch von WordPress geladen würden → Post neu abrufen (Hauptmenü 5, dann 1)
int wpImages = System.Text.RegularExpressions.Regex.Matches(html, @"<img\s[^>]*?\bsrc=[""'][^""']*/wp-content/uploads/", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count;
if (wpImages > 0)
Console.WriteLine($" [Warnung] {wpImages} Bild(er) im HTML zeigen noch auf WordPress (/wp-content/uploads/)");
// 4. Kategorien als Ghost-Tags erstellen/zuordnen.
// Der erste Tag ist in Ghost der primäre, alle weiteren sind sekundär.
var tags = new List<GhostAdminTag>(); var tags = new List<GhostAdminTag>();
var allTagSources = new List<WpCategory>(); var allTagSources = new List<WpCategory>();
@ -826,36 +832,41 @@ class Program
{ {
Console.WriteLine($" [{i + 1}] {postFile.Categories[i].Name}"); Console.WriteLine($" [{i + 1}] {postFile.Categories[i].Name}");
} }
Console.Write(" Relevante Kategorie (Nummer, Enter = erste): ");
string? catChoice = Console.ReadLine()?.Trim(); int catIdx;
if (int.TryParse(catChoice, out int catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count) while (true)
{ {
// Gewählte Kategorie an erste Stelle setzen Console.Write(" Welche ist die primäre Kategorie? (Nummer): ");
var primary = postFile.Categories[catIdx - 1]; string? catChoice = Console.ReadLine();
allTagSources.Add(primary); if (int.TryParse(catChoice?.Trim(), out catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count)
allTagSources.AddRange(postFile.Categories.Where((_, idx) => idx != catIdx - 1)); break;
Console.WriteLine($" -> Primäre Kategorie: {primary.Name}"); if (catChoice == null) { catIdx = 1; break; } // keine Eingabe möglich (stdin geschlossen)
} Console.WriteLine($" Bitte eine Zahl von 1 bis {postFile.Categories.Count} eingeben.");
else
{
allTagSources.AddRange(postFile.Categories);
Console.WriteLine($" -> Erste Kategorie: {postFile.Categories[0].Name}");
} }
// Gewählte Kategorie an erste Stelle setzen, alle anderen folgen als sekundäre
var primary = postFile.Categories[catIdx - 1];
allTagSources.Add(primary);
allTagSources.AddRange(postFile.Categories.Where((_, idx) => idx != catIdx - 1));
Console.WriteLine($" -> Primär: {primary.Name}");
Console.WriteLine($" -> Sekundär: {string.Join(", ", allTagSources.Skip(1).Select(c => c.Name))}");
} }
else if (postFile.Categories.Count == 1) else if (postFile.Categories.Count == 1)
{ {
allTagSources.AddRange(postFile.Categories); allTagSources.AddRange(postFile.Categories);
} }
if (postFile.Tags.Count > 0)
allTagSources.AddRange(postFile.Tags);
if (allTagSources.Count > 0) if (allTagSources.Count > 0)
{ {
Console.WriteLine($" [Tags] Verarbeite {postFile.Categories.Count} Kategorien + {postFile.Tags.Count} Tags..."); Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Kategorien...");
tags = await GhostApi.EnsureTagsAsync(allTagSources); tags = await GhostApi.EnsureTagsAsync(allTagSources);
} }
// 4b. Schlagwörter (WP-Tags + SEO-Keywords) ohne Nachfrage als <meta name="keywords">
var keywords = postFile.Tags.Select(t => t.Name ?? "")
.Concat((postFile.Keywords ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries))
.ToList();
// 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert // 5. Prüfe ob Post mit gleichem Slug bereits in Ghost existiert
Console.Write(" Prüfe Ghost..."); Console.Write(" Prüfe Ghost...");
var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{postFile.Slug}", 1); var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{postFile.Slug}", 1);
@ -865,21 +876,25 @@ class Program
if (existing != null) if (existing != null)
{ {
Console.WriteLine($" vorhanden (ID: {existing.Id})"); Console.WriteLine($" vorhanden (ID: {existing.Id})");
string? head = GhostApi.BuildKeywordsHead(existing.CodeinjectionHead, keywords);
if (head != null) Console.WriteLine(" [Meta] Schlagwörter als <meta name=\"keywords\"> gesetzt");
Console.Write(" Aktualisiere..."); Console.Write(" Aktualisiere...");
result = await GhostApi.UpdatePostExtendedAsync( result = await GhostApi.UpdatePostExtendedAsync(
existing.Id!, postFile.Title ?? "", html, existing.Id!, postFile.Title ?? "", html,
existing.UpdatedAt ?? "", postFile.Status ?? "published", existing.UpdatedAt ?? "", postFile.Status ?? "published",
featureImageUrl, tags, postFile.Keywords); featureImageUrl, tags, head);
Console.WriteLine(result != null ? " OK" : " FEHLER"); Console.WriteLine(result != null ? " OK" : " FEHLER");
} }
else else
{ {
Console.WriteLine(" nicht vorhanden"); Console.WriteLine(" nicht vorhanden");
string? head = GhostApi.BuildKeywordsHead(null, keywords);
if (head != null) Console.WriteLine(" [Meta] Schlagwörter als <meta name=\"keywords\"> gesetzt");
Console.Write(" Erstelle neuen Post..."); Console.Write(" Erstelle neuen Post...");
result = await GhostApi.CreatePostExtendedAsync( result = await GhostApi.CreatePostExtendedAsync(
postFile.Title ?? "", html, postFile.Title ?? "", html,
postFile.Status ?? "published", postFile.Slug, postFile.Status ?? "published", postFile.Slug,
featureImageUrl, tags, postFile.Keywords); featureImageUrl, tags, head);
Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER"); Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER");
} }

View file

@ -67,7 +67,7 @@ namespace wp2ghost
public static async Task<List<GhostAdminPost>> BrowsePostsAsync(string filter = "", int limit = 50) public static async Task<List<GhostAdminPost>> BrowsePostsAsync(string filter = "", int limit = 50)
{ {
string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image"; string endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,status,created_at,updated_at,html,feature_image,codeinjection_head";
var request = await CreateRequestAsync(HttpMethod.Get, endpoint); var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
var response = await httpClient.SendAsync(request); var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@ -151,15 +151,22 @@ namespace wp2ghost
// ===================== Tags ===================== // ===================== Tags =====================
public static async Task<List<GhostAdminTag>> BrowseTagsAsync(string filter = "", int limit = 100) public static async Task<List<GhostAdminTag>> BrowseTagsAsync(string filter = "", int limit = 100)
{ {
string endpoint = $"tags/?limit={limit}&fields=id,name,slug"; // Browse-Endpunkte sind paginiert -> alle Seiten laden
if (!string.IsNullOrEmpty(filter)) var all = new List<GhostAdminTag>();
endpoint += $"&filter={Uri.EscapeDataString(filter)}"; for (int page = 1; ; page++)
var request = await CreateRequestAsync(HttpMethod.Get, endpoint); {
var response = await httpClient.SendAsync(request); string endpoint = $"tags/?limit={limit}&page={page}&fields=id,name,slug";
if (!response.IsSuccessStatusCode) return new(); if (!string.IsNullOrEmpty(filter))
var json = await response.Content.ReadAsStringAsync(); endpoint += $"&filter={Uri.EscapeDataString(filter)}";
var result = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json); var request = await CreateRequestAsync(HttpMethod.Get, endpoint);
return result?.Tags ?? new(); var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) break;
var json = await response.Content.ReadAsStringAsync();
var tags = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json)?.Tags ?? new();
all.AddRange(tags);
if (tags.Count < limit) break;
}
return all;
} }
public static async Task<GhostAdminTag?> CreateTagAsync(string name, string? slug = null) public static async Task<GhostAdminTag?> CreateTagAsync(string name, string? slug = null)
@ -185,15 +192,19 @@ namespace wp2ghost
{ {
var result = new List<GhostAdminTag>(); var result = new List<GhostAdminTag>();
var existingTags = await BrowseTagsAsync(); var existingTags = await BrowseTagsAsync();
var existingByName = existingTags.Where(t => t.Name != null).ToDictionary(t => t.Name!.ToLowerInvariant(), t => t); var existingByName = new Dictionary<string, GhostAdminTag>();
foreach (var t in existingTags.Where(t => t.Name != null))
existingByName.TryAdd(t.Name!.ToLowerInvariant(), t);
foreach (var cat in categories) foreach (var cat in categories)
{ {
string name = cat.Name ?? ""; // WordPress liefert Namen HTML-kodiert (z.B. "Tipps &amp; Tricks")
string name = System.Net.WebUtility.HtmlDecode(cat.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) continue; if (string.IsNullOrEmpty(name)) continue;
if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing)) if (existingByName.TryGetValue(name.ToLowerInvariant(), out var existing))
{ {
result.Add(existing); // Reihenfolge bleibt erhalten: der erste Tag ist in Ghost der primäre
if (!result.Contains(existing)) result.Add(existing);
} }
else else
{ {
@ -203,6 +214,7 @@ namespace wp2ghost
{ {
Console.WriteLine(" OK"); Console.WriteLine(" OK");
result.Add(newTag); result.Add(newTag);
existingByName[name.ToLowerInvariant()] = newTag;
} }
else else
Console.WriteLine(" FEHLER"); Console.WriteLine(" FEHLER");
@ -262,7 +274,30 @@ namespace wp2ghost
} }
// ===================== Posts (erweitert) ===================== // ===================== Posts (erweitert) =====================
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? metaKeywords = null) private static readonly System.Text.RegularExpressions.Regex MetaKeywordsRegex = new(
@"<meta\s+name\s*=\s*[""']keywords[""'][^>]*>\s*",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
/// <summary>
/// Ghost kennt kein meta_keywords-Feld -> Keywords als &lt;meta name="keywords"&gt; in den
/// Code-Injection-Header des Posts schreiben. Vorhandener Header-Code bleibt erhalten,
/// ein bereits vorhandenes Keywords-Meta wird ersetzt.
/// </summary>
public static string? BuildKeywordsHead(string? existingHead, IEnumerable<string> keywords)
{
var list = keywords
.Select(k => System.Net.WebUtility.HtmlDecode(k ?? "").Trim())
.Where(k => k.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (list.Count == 0) return null;
string rest = MetaKeywordsRegex.Replace(existingHead ?? "", "").Trim();
string meta = $"<meta name=\"keywords\" content=\"{System.Net.WebUtility.HtmlEncode(string.Join(", ", list))}\">";
return rest.Length > 0 ? rest + "\n" + meta : meta;
}
public static async Task<GhostAdminPost?> CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null)
{ {
var post = new GhostAdminPostEdit var post = new GhostAdminPostEdit
{ {
@ -270,7 +305,7 @@ namespace wp2ghost
Html = html, Html = html,
Status = status, Status = status,
FeatureImage = featureImage, FeatureImage = featureImage,
MetaKeywords = metaKeywords CodeinjectionHead = codeinjectionHead
}; };
if (slug != null) post.Slug = slug; if (slug != null) post.Slug = slug;
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList(); if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
@ -290,7 +325,7 @@ namespace wp2ghost
return result?.Posts?.FirstOrDefault(); return result?.Posts?.FirstOrDefault();
} }
public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? metaKeywords = null) public static async Task<GhostAdminPost?> UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List<GhostAdminTag> tags, string? codeinjectionHead = null)
{ {
var post = new GhostAdminPostEdit var post = new GhostAdminPostEdit
{ {
@ -300,7 +335,7 @@ namespace wp2ghost
Status = status, Status = status,
UpdatedAt = updatedAt, UpdatedAt = updatedAt,
FeatureImage = featureImage, FeatureImage = featureImage,
MetaKeywords = metaKeywords CodeinjectionHead = codeinjectionHead
}; };
if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList(); if (tags.Count > 0) post.Tags = tags.Select(t => new GhostAdminTagRef { Id = t.Id, Name = t.Name }).ToList();
@ -522,6 +557,9 @@ namespace wp2ghost
[JsonPropertyName("featured")] [JsonPropertyName("featured")]
public bool Featured { get; set; } public bool Featured { get; set; }
[JsonPropertyName("codeinjection_head")]
public string? CodeinjectionHead { get; set; }
} }
public class GhostAdminPostEdit public class GhostAdminPostEdit
@ -566,9 +604,9 @@ namespace wp2ghost
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MetaDescription { get; set; } public string? MetaDescription { get; set; }
[JsonPropertyName("meta_keywords")] [JsonPropertyName("codeinjection_head")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MetaKeywords { get; set; } public string? CodeinjectionHead { get; set; }
} }
public class GhostImageUploadResponse public class GhostImageUploadResponse

View file

@ -13,6 +13,7 @@ namespace wp2ghost
[JsonPropertyName("slug")] public string? Slug { get; set; } [JsonPropertyName("slug")] public string? Slug { get; set; }
[JsonPropertyName("html")] public string? Html { get; set; } [JsonPropertyName("html")] public string? Html { get; set; }
[JsonPropertyName("markdown")] public string? Markdown { get; set; } [JsonPropertyName("markdown")] public string? Markdown { get; set; }
[JsonPropertyName("feature_image")] public string? FeatureImage { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; } [JsonPropertyName("status")] public string? Status { get; set; }
[JsonPropertyName("created_at")] public string? CreatedAt { get; set; } [JsonPropertyName("created_at")] public string? CreatedAt { get; set; }
[JsonPropertyName("published_at")] public string? PublishedAt { get; set; } [JsonPropertyName("published_at")] public string? PublishedAt { get; set; }

View file

@ -503,6 +503,11 @@ namespace wp2ghost
string src = GetBestImageUrl(img); string src = GetBestImageUrl(img);
if (string.IsNullOrEmpty(src)) continue; if (string.IsNullOrEmpty(src)) continue;
// Beste Variante als src setzen; srcset/sizes verweisen auf WordPress-Größenvarianten
img.SetAttributeValue("src", src);
img.Attributes.Remove("srcset");
img.Attributes.Remove("sizes");
var parentFigure = img.Ancestors("figure").FirstOrDefault(); var parentFigure = img.Ancestors("figure").FirstOrDefault();
if (parentFigure != null) if (parentFigure != null)
{ {

View file

@ -1,6 +1,6 @@
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
using System.Diagnostics.Metrics; using System.Text.RegularExpressions;
namespace wp2ghost namespace wp2ghost
{ {
@ -10,42 +10,64 @@ namespace wp2ghost
static class ImageManager static class ImageManager
{ {
private static readonly HttpClient httpClient = new(); private static readonly HttpClient httpClient = new();
private static Dictionary<string, (string RelativePath, string LocalPath)> Cache = new(); // Schlüssel: Bild-Schlüssel (siehe GetImageKey) — alle WordPress-Varianten eines Bildes teilen sich einen Eintrag
private static Dictionary<string, string> WordpressToGhostImageName = new(); private static Dictionary<string, (string SourceUrl, string RelativePath, string LocalPath)> Cache = new();
private static List<(string RelativePath, string LocalPath)> FilesToZip = new(); private static List<(string RelativePath, string LocalPath)> FilesToZip = new();
private static string LocalImageDir = ""; private static string LocalImageDir = "";
private static string CachePath = ""; private static string CachePath = "";
private static readonly Regex SizeSuffixRegex = new(@"-\d+x\d+(?=\.\w+$)", RegexOptions.Compiled);
private static readonly Regex ScaledSuffixRegex = new(@"-scaled(?=\.\w+$)", RegexOptions.Compiled);
public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip; public static IReadOnlyList<(string RelativePath, string LocalPath)> CachedFiles => FilesToZip;
/// <summary>
/// Schlüssel, unter dem alle Varianten desselben WordPress-Bildes zusammenfallen:
/// Pfad ohne Host, Query (?w=1024), Größen-Suffix (-1024x683) und -scaled.
/// </summary>
public static string GetImageKey(string url)
{
string path;
try { path = new Uri(url).AbsolutePath; }
catch { path = url.Split('?', '#')[0]; }
path = SizeSuffixRegex.Replace(path, "");
path = ScaledSuffixRegex.Replace(path, "");
return path.ToLowerInvariant();
}
public static bool TryGetRelativePath(string url, out string relativePath) public static bool TryGetRelativePath(string url, out string relativePath)
{ {
// 1. Direkte URL-Prüfung im Cache if (TryGetCached(GetImageKey(url), out var cached))
if (Cache.TryGetValue(url, out var cached))
{ {
relativePath = cached.RelativePath; relativePath = cached.RelativePath;
return true; return true;
} }
// 2. Prüfung über WordPress-Dateinamen (fürFeatured Images mit anderer URL)
string wpName = Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath);
if (WordpressToGhostImageName.TryGetValue(wpName, out string? relPath) && !string.IsNullOrEmpty(relPath))
{
string localPath = Path.Combine(LocalImageDir, relPath);
if (File.Exists(localPath))
{
relativePath = relPath;
// Auch im Cache eintragen für zukünftige Suche
Cache[url] = (relPath, localPath);
return true;
}
WordpressToGhostImageName.Remove(wpName);
}
relativePath = ""; relativePath = "";
return false; return false;
} }
private static bool TryGetCached(string key, out (string RelativePath, string LocalPath) file)
{
if (Cache.TryGetValue(key, out var cached))
{
if (File.Exists(cached.LocalPath))
{
file = (cached.RelativePath, cached.LocalPath);
RegisterForZip(file);
return true;
}
Cache.Remove(key);
}
file = default;
return false;
}
private static void RegisterForZip((string RelativePath, string LocalPath) file)
{
if (!FilesToZip.Any(f => f.RelativePath == file.RelativePath))
FilesToZip.Add(file);
}
public static void Init(string baseDir) public static void Init(string baseDir)
{ {
LocalImageDir = Path.Combine(baseDir, "images"); LocalImageDir = Path.Combine(baseDir, "images");
@ -57,16 +79,13 @@ namespace wp2ghost
LoadCache(); LoadCache();
} }
public static string ImageContainerPath => LocalImageDir; public static string ImageContainerPath => LocalImageDir;
public static void SaveCache() public static void SaveCache()
{ {
var lines = new List<string>(); var lines = new List<string>();
foreach (var kvp in Cache) foreach (var kvp in Cache)
{ lines.Add($"{kvp.Value.SourceUrl}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{kvp.Key}");
string wpName = Path.GetFileNameWithoutExtension(new Uri(kvp.Key).AbsolutePath);
lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{wpName}");
}
File.WriteAllLines(CachePath, lines); File.WriteAllLines(CachePath, lines);
} }
@ -89,7 +108,7 @@ namespace wp2ghost
{ {
Console.WriteLine("\nLokal gespeicherte Bilder:"); Console.WriteLine("\nLokal gespeicherte Bilder:");
foreach (var kvp in Cache.Take(20)) foreach (var kvp in Cache.Take(20))
Console.WriteLine($" {kvp.Key} -> images/{kvp.Value.RelativePath}"); Console.WriteLine($" {kvp.Value.SourceUrl} -> images/{kvp.Value.RelativePath}");
if (Cache.Count > 20) if (Cache.Count > 20)
Console.WriteLine($" ... und {Cache.Count - 20} weitere"); Console.WriteLine($" ... und {Cache.Count - 20} weitere");
} }
@ -103,7 +122,7 @@ namespace wp2ghost
var uri = new Uri(url); var uri = new Uri(url);
string fileName = Path.GetFileName(uri.LocalPath); string fileName = Path.GetFileName(uri.LocalPath);
// WordPress-Sizes: -300x200, -scaled, -1024x768 etc. // WordPress-Sizes: -300x200, -scaled, -1024x768 etc.
if (System.Text.RegularExpressions.Regex.IsMatch(fileName, @"-\d+x\d+(?=\.\w+$)")) if (SizeSuffixRegex.IsMatch(fileName))
return false; return false;
if (fileName.Contains("-scaled")) if (fileName.Contains("-scaled"))
return false; return false;
@ -112,86 +131,85 @@ namespace wp2ghost
catch { return false; } catch { return false; }
} }
public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string imageUrl, DateTime? postDate) /// <summary>
/// Lädt EIN Bild für eine Gruppe von URL-Varianten desselben WordPress-Bildes
/// (Original, -scaled, -1024x683, ?w=...). Es wird die beste verfügbare Variante geladen.
/// </summary>
public static async Task<(string RelativePath, string LocalPath)?> DownloadBestAsync(IEnumerable<string> variants, DateTime? postDate)
{ {
if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) // Query/Fragment entfernen: ?w=1024 & Co. liefern höchstens eine verkleinerte Fassung
return null; var urls = variants
.Where(u => !string.IsNullOrEmpty(u))
.Select(u => u.Split('?', '#')[0])
.Where(u => Uri.IsWellFormedUriString(u, UriKind.Absolute))
.Distinct()
.ToList();
if (urls.Count == 0) return null;
string wpImageName = Path.GetFileNameWithoutExtension(new Uri(imageUrl).AbsolutePath); string key = GetImageKey(urls[0]);
if (TryGetCached(key, out var cachedFile))
return cachedFile;
if (WordpressToGhostImageName.TryGetValue(wpImageName, out string? ghostRelativePath) && !string.IsNullOrEmpty(ghostRelativePath)) // Reihenfolge: Original -> -scaled -> erratenes Original -> Größenvarianten (größte zuerst)
var candidates = new List<string>();
candidates.AddRange(urls.Where(IsMainImage));
candidates.AddRange(urls.Where(u => ScaledSuffixRegex.IsMatch(u) && !SizeSuffixRegex.IsMatch(u)));
candidates.Add(ScaledSuffixRegex.Replace(SizeSuffixRegex.Replace(urls[0], ""), ""));
candidates.AddRange(urls.Where(u => SizeSuffixRegex.IsMatch(u)).OrderByDescending(GetVariantArea));
string lastError = "";
foreach (string candidate in candidates.Distinct())
{ {
string localPath = Path.Combine(LocalImageDir, ghostRelativePath); try
if (File.Exists(localPath))
{ {
Console.WriteLine($" [Bild] Bereits heruntergeladen (Wörterbuch): {wpImageName} → {ghostRelativePath}"); byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate);
return (ghostRelativePath, localPath);
}
WordpressToGhostImageName.Remove(wpImageName);
}
if (Cache.TryGetValue(imageUrl, out var cachedFile)) var now = DateTime.Now;
{ string datePath = $"{now:yyyy}/{now:MM}";
if (File.Exists(cachedFile.LocalPath)) string localDir = Path.Combine(LocalImageDir, datePath);
return cachedFile; if (!Directory.Exists(localDir))
Cache.Remove(imageUrl); Directory.CreateDirectory(localDir);
}
try // Eindeutigen Dateinamen bestimmen (nie eine vorhandene Datei wiederverwenden)
{ var pDate = postDate ?? now;
string fileName = Path.GetFileName(new Uri(imageUrl).AbsolutePath); string ext = Path.GetExtension(new Uri(candidate).AbsolutePath);
string urlHash = GetHashCode(imageUrl); int counter = 1 + new DirectoryInfo(localDir).GetFiles().Length;
var datePrefix = DateTime.Now; string relativePath, localPath;
string datePath = $"{datePrefix:yyyy}/{datePrefix:MM}"; do
var localPath = Path.Combine(LocalImageDir, datePath); {
if (!string.IsNullOrEmpty(localPath) && !Directory.Exists(localPath)) relativePath = $"{datePath}/{pDate:yyyyMMdd}_{counter++}{ext}";
Directory.CreateDirectory(localPath); localPath = Path.Combine(LocalImageDir, relativePath);
} while (File.Exists(localPath));
var counter = 1 + new DirectoryInfo(localPath).GetFiles().Length;
var pDate = postDate ?? DateTime.Now;
string uniqueFileName = $"{pDate:yyyymmdd}_{counter}{Path.GetExtension(fileName)}";
string relativePath = $"{datePath}/{uniqueFileName}";
string fallBackPath = $"{datePath}/{uniqueFileName}";
localPath = Path.Combine(LocalImageDir, relativePath);
fallBackPath = Path.Combine(LocalImageDir, fallBackPath);
if (File.Exists(fallBackPath))
{
File.Move(fallBackPath, localPath);
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}");
}
else if (!File.Exists(localPath))
{
byte[] imageBytes = await httpClient.GetByteArrayAsync(imageUrl);
await File.WriteAllBytesAsync(localPath, imageBytes); await File.WriteAllBytesAsync(localPath, imageBytes);
Console.WriteLine($" [Bild] Heruntergeladen: {relativePath}"); Console.WriteLine($" [Bild] Heruntergeladen: {Path.GetFileName(new Uri(candidate).AbsolutePath)} → {relativePath}");
ResizeImage(localPath);
var fileResult = (relativePath, localPath);
Cache[key] = (candidate, relativePath, localPath);
RegisterForZip(fileResult);
return fileResult;
} }
else catch (Exception ex)
{ {
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}"); lastError = ex.Message;
} }
ResizeImage(localPath);
var fileResult = (relativePath, localPath);
Cache[imageUrl] = fileResult;
WordpressToGhostImageName[wpImageName] = relativePath;
FilesToZip.Add(fileResult);
return fileResult;
}
catch (Exception ex)
{
Console.WriteLine($" [Bild-Fehler] Konnte {imageUrl} nicht laden: {ex.Message}");
return null;
} }
Console.WriteLine($" [Bild-Fehler] Konnte {urls[0]} nicht laden: {lastError}");
return null;
}
private static long GetVariantArea(string url)
{
var m = Regex.Match(url, @"-(\d+)x(\d+)(?=\.\w+$)");
return m.Success ? long.Parse(m.Groups[1].Value) * long.Parse(m.Groups[2].Value) : 0;
} }
private static void ResizeImage(string localPath) private static void ResizeImage(string localPath)
{ {
const int maxSize = 1024; const int maxSize = 2048;
try try
{ {
using var image = Image.Load(localPath); using var image = Image.Load(localPath);
@ -223,17 +241,6 @@ namespace wp2ghost
} }
} }
private static string GetHashCode(string input)
{
unchecked
{
int hash = 17;
foreach (char c in input)
hash = hash * 23 + c;
return hash.ToString("x8");
}
}
private static void LoadCache() private static void LoadCache()
{ {
if (!File.Exists(CachePath)) return; if (!File.Exists(CachePath)) return;
@ -245,10 +252,8 @@ namespace wp2ghost
var parts = line.Split('|'); var parts = line.Split('|');
if (parts.Length >= 3) if (parts.Length >= 3)
{ {
Cache[parts[0]] = (parts[1], parts[2]); // Schlüssel immer aus der URL ableiten (ältere Cache-Dateien enthalten nur den Dateinamen)
// WordPress-Dateinamen auch ins Dictionary laden Cache[GetImageKey(parts[0])] = (parts[0], parts[1], parts[2]);
if (parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]))
WordpressToGhostImageName[parts[3]] = parts[1];
} }
} }
} }
@ -258,4 +263,4 @@ namespace wp2ghost
} }
} }
} }
} }

View file

@ -143,71 +143,54 @@ namespace wp2ghost
} }
} }
// Alle internen Bild-URLs aus dem HTML extrahieren (keine externen Links) // Alle internen Bild-URLs im HTML (src, srcset, data-*, href) — keine externen Links
var htmlImageUrls = ExtractImageUrls(htmlContent, wpHost); var htmlImageUrls = ExtractImageUrls(htmlContent, wpHost);
// Bevorzugte Media-Dateien ermitteln // Varianten desselben Bildes (Original, -scaled, -1024x683, ?w=...) zusammenfassen:
var preferredMedia = GetPreferredMediaFiles(wpPost.MediaFiles, htmlImageUrls, wpHost); // pro Bild wird genau EINE Datei geladen, auf die alle Varianten umgeschrieben werden.
var imageGroups = new Dictionary<string, List<string>>();
// Prüfe: Referenzierte Bilder im HTML, die NICHT in MediaFiles sind var mediaUrls = (wpPost.MediaFiles ?? new()).Where(u => IsInternalUrl(u, wpHost)).ToList();
if (htmlImageUrls.Count > 0 && wpPost.MediaFiles != null) if (!string.IsNullOrEmpty(wpPost.FeaturedImage))
mediaUrls.Add(wpPost.FeaturedImage);
foreach (string url in htmlImageUrls.Concat(mediaUrls))
{ {
var mediaFileNames = wpPost.MediaFiles.Select(m => GetFileNameFromUrl(m).ToLowerInvariant()).ToHashSet(); string key = ImageManager.GetImageKey(url);
foreach (var imgUrl in htmlImageUrls) if (!imageGroups.TryGetValue(key, out var variants))
{ imageGroups[key] = variants = new List<string>();
string imgFileName = GetFileNameFromUrl(imgUrl).ToLowerInvariant(); variants.Add(url);
if (!mediaFileNames.Contains(imgFileName))
{
Console.WriteLine($" [Warnung] Bild im HTML nicht in MediaFiles: {Path.GetFileName(new Uri(imgUrl).AbsolutePath)}");
}
}
} }
if (preferredMedia.Count > 0) var imageFiles = new List<string>();
var ghostPathByKey = new Dictionary<string, string>();
foreach (var group in imageGroups)
{ {
foreach (string mediaUrl in preferredMedia) var downloaded = await ImageManager.DownloadBestAsync(group.Value, postDate);
{ if (!downloaded.HasValue) continue;
var downloaded = await ImageManager.DownloadAsync(mediaUrl, postDate); ghostPathByKey[group.Key] = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}";
if (downloaded.HasValue) if (!imageFiles.Contains(downloaded.Value.RelativePath))
{ imageFiles.Add(downloaded.Value.RelativePath);
string ghostMediaPath = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}"; }
htmlContent = htmlContent.Replace(mediaUrl, ghostMediaPath);
} // Längste URLs zuerst ersetzen, damit "bild.jpg" nicht in "bild.jpg?w=1024" hineinersetzt
} foreach (string url in htmlImageUrls.OrderByDescending(u => u.Length))
{
if (ghostPathByKey.TryGetValue(ImageManager.GetImageKey(url), out string? ghostMediaPath))
htmlContent = htmlContent.Replace(url, ghostMediaPath);
} }
// WordPress HTML zu Ghost-kompatibles HTML konvertieren (Galerien, Figures, etc.) // WordPress HTML zu Ghost-kompatibles HTML konvertieren (Galerien, Figures, etc.)
htmlContent = HtmlGhostConverter.Convert(htmlContent); htmlContent = HtmlGhostConverter.Convert(htmlContent);
// Lokale Bildpfade für JSON-Datei sammeln int remainingWpImages = ExtractImageUrls(htmlContent, wpHost).Count;
var imageFiles = new List<string>(); if (remainingWpImages > 0)
if (preferredMedia.Count > 0) Console.WriteLine($" [Warnung] {remainingWpImages} Bild-URL(s) zeigen weiterhin auf WordPress");
{
foreach (string mediaUrl in preferredMedia)
{
if (ImageManager.TryGetRelativePath(mediaUrl, out string relPath))
imageFiles.Add(relPath);
}
}
// Featured Image verarbeiten // Featured Image (wurde oben mit heruntergeladen)
string featureImageGhostPath = ""; string featureImageGhostPath = "";
if (!string.IsNullOrEmpty(wpPost.FeaturedImage)) if (!string.IsNullOrEmpty(wpPost.FeaturedImage)
&& ghostPathByKey.TryGetValue(ImageManager.GetImageKey(wpPost.FeaturedImage), out string? featPath))
{ {
if (ImageManager.TryGetRelativePath(wpPost.FeaturedImage, out string featRel)) featureImageGhostPath = featPath;
{
featureImageGhostPath = $"__GHOST_URL__/content/images/{featRel}";
}
else
{
var featDownload = await ImageManager.DownloadAsync(wpPost.FeaturedImage, postDate);
if (featDownload.HasValue)
{
featureImageGhostPath = $"__GHOST_URL__/content/images/{featDownload.Value.RelativePath}";
if (!imageFiles.Contains(featDownload.Value.RelativePath))
imageFiles.Add(featDownload.Value.RelativePath);
}
}
} }
var ghostPost = new GhostPost var ghostPost = new GhostPost
@ -216,6 +199,7 @@ namespace wp2ghost
Title = wpPost.Title, Title = wpPost.Title,
Slug = wpPost.Slug, Slug = wpPost.Slug,
Html = htmlContent, Html = htmlContent,
FeatureImage = string.IsNullOrEmpty(featureImageGhostPath) ? null : featureImageGhostPath,
Status = "published", Status = "published",
CreatedAt = postDate.ToString("o"), CreatedAt = postDate.ToString("o"),
PublishedAt = postDate.ToString("o") PublishedAt = postDate.ToString("o")
@ -285,83 +269,24 @@ namespace wp2ghost
// Console.WriteLine($"Markdown-Dateien gespeichert: {GhostPosts.Count} Dateien unter {MarkdownDir}"); // Console.WriteLine($"Markdown-Dateien gespeichert: {GhostPosts.Count} Dateien unter {MarkdownDir}");
//} //}
// Bild-URL irgendwo im HTML: src, srcset, data-orig-file, data-large-file, href ...
// inkl. Query (?w=1024), damit beim Ersetzen nichts davon stehen bleibt.
private static readonly System.Text.RegularExpressions.Regex ImageUrlRegex = new(
@"https?://[^\s""'<>()]+?\.(?:jpe?g|png|gif|webp|svg|bmp|tiff?)(?:\?[^\s""'<>]*)?(?=[\s""'<>,)&\\]|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static List<string> ExtractImageUrls(string html, string wpHost) private static List<string> ExtractImageUrls(string html, string wpHost)
{ {
var urls = new List<string>(); var urls = new List<string>();
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" }; foreach (System.Text.RegularExpressions.Match m in ImageUrlRegex.Matches(html))
// 1) <img src="..."> extrahieren
var imgMatches = System.Text.RegularExpressions.Regex.Matches(html, @"<img\s+[^>]*src=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in imgMatches)
{ {
string url = m.Groups[1].Value; string url = m.Value;
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url)) if (IsInternalUrl(url, wpHost) && !urls.Contains(url))
urls.Add(url); urls.Add(url);
} }
// 2) data-orig-file (Vollbild) extrahieren
var origFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-orig-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in origFileMatches)
{
string url = m.Groups[1].Value;
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
urls.Add(url);
}
// 3) data-large-file extrahieren
var largeFileMatches = System.Text.RegularExpressions.Regex.Matches(html, @"data-large-file=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in largeFileMatches)
{
string url = m.Groups[1].Value;
if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
urls.Add(url);
}
// 4) srcset (größte Variante pro img) extrahieren
var srcsetMatches = System.Text.RegularExpressions.Regex.Matches(html, @"srcset=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in srcsetMatches)
{
string srcset = m.Groups[1].Value;
string widestUrl = GetWidestFromSrcset(srcset);
if (!string.IsNullOrEmpty(widestUrl) && IsInternalUrl(widestUrl, wpHost) && !urls.Contains(widestUrl))
urls.Add(widestUrl);
}
// 5) <a href="..."> mit Bildendung (Figure-Links auf Originalbild)
var aMatches = System.Text.RegularExpressions.Regex.Matches(html, @"<a\s+[^>]*href=[""']([^""']+)[""'][^>]*>", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in aMatches)
{
string url = m.Groups[1].Value;
if (string.IsNullOrEmpty(url)) continue;
try
{
string ext = Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant();
if (imageExts.Contains(ext) && IsInternalUrl(url, wpHost) && !urls.Contains(url))
urls.Add(url);
}
catch { }
}
return urls; return urls;
} }
private static string GetWidestFromSrcset(string srcset)
{
string bestUrl = "";
int bestWidth = 0;
foreach (string part in srcset.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
string[] tokens = part.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 2) continue;
if (int.TryParse(tokens[1].Replace("w", ""), out int w) && w > bestWidth)
{
bestWidth = w;
bestUrl = tokens[0];
}
}
return bestUrl;
}
private static bool IsInternalUrl(string url, string wpHost) private static bool IsInternalUrl(string url, string wpHost)
{ {
if (string.IsNullOrEmpty(wpHost)) return true; if (string.IsNullOrEmpty(wpHost)) return true;
@ -376,107 +301,6 @@ namespace wp2ghost
} }
} }
private static string NormalizeImageUrl(string url)
{
try
{
var uri = new Uri(url);
string path = uri.AbsolutePath;
// Entferne -scaled UND Dimensionspostfixe für den Vergleich
path = System.Text.RegularExpressions.Regex.Replace(path, @"-scaled(?=\.\w+$)", "");
path = System.Text.RegularExpressions.Regex.Replace(path, @"-\d+x\d+(?=\.\w+$)", "");
return path;
}
catch
{
return url;
}
}
private static string GetFileNameFromUrl(string url)
{
try
{
return Path.GetFileName(new Uri(url).AbsolutePath);
}
catch
{
return url;
}
}
private static List<string> GetPreferredMediaFiles(List<string>? mediaFiles, List<string> htmlImageUrls, string wpHost)
{
var result = new List<string>();
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" };
// Alle internen Bild-URLs zusammenführen: MediaFiles + HTML-referenzierte
var allUrls = new List<string>();
if (mediaFiles != null)
allUrls.AddRange(mediaFiles.Where(u => IsInternalUrl(u, wpHost)));
foreach (var u in htmlImageUrls)
{
if (!allUrls.Contains(u))
allUrls.Add(u);
}
if (allUrls.Count == 0)
return result;
// Dateinamen aus den HTML-Links extrahieren
var htmlFileNames = htmlImageUrls.Select(u => GetFileNameFromUrl(u).ToLowerInvariant()).ToHashSet();
// Gruppiere nach normalisiertem Dateinamen (ohne -scaled und Dimensionspostfixe)
var grouped = new Dictionary<string, List<string>>();
foreach (var url in allUrls)
{
string normalized = NormalizeImageUrl(url);
if (!grouped.ContainsKey(normalized))
grouped[normalized] = new List<string>();
grouped[normalized].Add(url);
}
foreach (var group in grouped)
{
// Nur Hauptbilder berücksichtigen (keine WordPress-Sizes wie -300x200, keine -scaled)
var mainImages = group.Value.Where(u => ImageManager.IsMainImage(u)).ToList();
if (mainImages.Count == 0)
{
Console.WriteLine($" [Bild] Übersprungen (kein Hauptbild): {Path.GetFileName(new Uri(@group.Value[0]).AbsolutePath)}");
continue;
}
if (mainImages.Count == 1)
{
result.Add(mainImages[0]);
}
else
{
// Prüfe ob eine der Varianten als <a href> im HTML referenziert wird
string? linked = mainImages.FirstOrDefault(u => htmlFileNames.Contains(GetFileNameFromUrl(u).ToLowerInvariant()));
if (linked != null)
{
result.Add(linked);
var skipped = mainImages.Where(u => u != linked).ToList();
foreach (var s in skipped)
Console.WriteLine($" [Bild] Übersprungen (nicht verlinkt): {Path.GetFileName(new Uri(s).AbsolutePath)}");
}
else
{
// Keine Verlinkung: Original (ohne -scaled) bevorzugen
var preferred = mainImages.FirstOrDefault(u => !u.Contains("-scaled")) ?? mainImages[0];
result.Add(preferred);
var skipped = mainImages.Where(u => u != preferred).ToList();
foreach (var s in skipped)
Console.WriteLine($" [Bild] Übersprungen (scaled): {Path.GetFileName(new Uri(s).AbsolutePath)}");
}
}
}
return result;
}
private static void MarkProcessed(int wpId, string? wpSlug = null) private static void MarkProcessed(int wpId, string? wpSlug = null)
{ {
ProcessedPosts.Add(wpId); ProcessedPosts.Add(wpId);

View file

@ -60,6 +60,16 @@ else
} }
</select> </select>
</div> </div>
<div class="col-md-2">
<label class="form-label">Kategorie</label>
<select class="form-select" @bind="_categoryFilter" @bind:after="ApplyFilters">
<option value="">Alle Kategorien</option>
@foreach (var category in AvailableCategories)
{
<option value="@category.Key">@category.Value</option>
}
</select>
</div>
<div class="col-md-4 d-flex align-items-end"> <div class="col-md-4 d-flex align-items-end">
<button class="btn btn-outline-secondary me-2" @onclick="ResetFilters" title="Filter zurücksetzen"> <button class="btn btn-outline-secondary me-2" @onclick="ResetFilters" title="Filter zurücksetzen">
<i class="bi bi-x-circle"></i> <i class="bi bi-x-circle"></i>
@ -118,18 +128,19 @@ else
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Feature)" @onclick="() => SetFilter(ImageFilter.Feature)">Feature (@_featureCount)</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-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> <button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button>
<button class="btn btn-outline-secondary @GetFilterClass(ImageFilter.Category)" @onclick="() => SetFilter(ImageFilter.Category)">Kategorie (@_categoryCount)</button>
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<span class="text-muted small"> <span class="text-muted small">
Gefiltert: <strong>@_filteredImages.Count</strong> von @_allImages.Count Gefiltert: <strong>@_filteredImages.Count</strong> von @_allImages.Count
@if (!string.IsNullOrWhiteSpace(_authorFilter) || !string.IsNullOrWhiteSpace(_themeFilter)) @if (!string.IsNullOrWhiteSpace(_authorFilter) || !string.IsNullOrWhiteSpace(_themeFilter) || !string.IsNullOrWhiteSpace(_categoryFilter))
{ {
<span class="ms-2"> <text>
(@(!string.IsNullOrWhiteSpace(_authorFilter) ? "Autor" : "") @if (!string.IsNullOrWhiteSpace(_authorFilter)): @:Autor
@(!string.IsNullOrWhiteSpace(_authorFilter) && !string.IsNullOrWhiteSpace(_themeFilter) ? " + " : "") @if (!string.IsNullOrWhiteSpace(_themeFilter)): @:Thema
@(!string.IsNullOrWhiteSpace(_themeFilter) ? "Thema" : "")) @if (!string.IsNullOrWhiteSpace(_categoryFilter)): @:Kategorie
</span> </text>
} }
</span> </span>
</div> </div>
@ -437,6 +448,7 @@ else
private int _orphanedCount; private int _orphanedCount;
private int _featureCount; private int _featureCount;
private int _contentCount; private int _contentCount;
private string _categoryFilter = "";
private GhostImage? _selectedImage; private GhostImage? _selectedImage;
private bool _showReplaceDialog; private bool _showReplaceDialog;
@ -518,6 +530,7 @@ else
_orphanedCount = _allImages.Count(i => i.IsOrphaned); _orphanedCount = _allImages.Count(i => i.IsOrphaned);
_featureCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsFeatureImage)); _featureCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsFeatureImage));
_contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent)); _contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent));
_categoryCount = _allImages.Count(i => !string.IsNullOrEmpty(i.Category));
} }
private List<GhostImage> FilteredImages => _filteredImages; private List<GhostImage> FilteredImages => _filteredImages;
@ -538,8 +551,16 @@ else
.OrderBy(t => t) .OrderBy(t => t)
.ToList(); .ToList();
private List<KeyValuePair<string, string>> AvailableCategories => _allImages
.Where(i => !string.IsNullOrEmpty(i.Category))
.GroupBy(i => i.Category!)
.Select(g => new KeyValuePair<string, string>(g.Key, g.Key))
.OrderBy(kvp => kvp.Value)
.ToList();
private int GetAuthorCount(string authorId) => _allImages.Count(i => i.AuthorId == authorId); private int GetAuthorCount(string authorId) => _allImages.Count(i => i.AuthorId == authorId);
private int GetThemeCount(string theme) => _allImages.Count(i => i.Theme == theme); private int GetThemeCount(string theme) => _allImages.Count(i => i.Theme == theme);
private int GetCategoryCount(string category) => _allImages.Count(i => i.Category == category);
private void ApplyFilters() private void ApplyFilters()
{ {
@ -558,6 +579,9 @@ else
if (!string.IsNullOrWhiteSpace(_themeFilter)) if (!string.IsNullOrWhiteSpace(_themeFilter))
query = query.Where(i => i.Theme == _themeFilter); query = query.Where(i => i.Theme == _themeFilter);
if (!string.IsNullOrWhiteSpace(_categoryFilter))
query = query.Where(i => i.Category == _categoryFilter);
_filteredImages = query.ToList(); _filteredImages = query.ToList();
StateHasChanged(); StateHasChanged();
} }
@ -566,6 +590,7 @@ else
{ {
_authorFilter = ""; _authorFilter = "";
_themeFilter = ""; _themeFilter = "";
_categoryFilter = "";
_searchTerm = ""; _searchTerm = "";
_currentFilter = ImageFilter.All; _currentFilter = ImageFilter.All;
ApplyFilters(); ApplyFilters();
@ -858,6 +883,7 @@ else
All, All,
Feature, Feature,
Content, Content,
Orphaned Orphaned,
Category
} }
} }

View file

@ -36,6 +36,9 @@ public class AppDbContext : DbContext
entity.Property(e => e.Iso).HasMaxLength(50); entity.Property(e => e.Iso).HasMaxLength(50);
entity.Property(e => e.FocalLength).HasMaxLength(50); entity.Property(e => e.FocalLength).HasMaxLength(50);
entity.Property(e => e.Theme).HasMaxLength(500); entity.Property(e => e.Theme).HasMaxLength(500);
entity.Property(e => e.Category).HasMaxLength(200);
entity.Property(e => e.CategorySecondary).HasMaxLength(200);
entity.Property(e => e.Keywords).HasMaxLength(500);
}); });
} }
} }

View file

@ -41,4 +41,10 @@ public class ManagedImage
public DateTime FirstSeen { get; set; } public DateTime FirstSeen { get; set; }
public DateTime LastUpdated { get; set; } public DateTime LastUpdated { get; set; }
public bool IsOrphaned { get; set; } public bool IsOrphaned { get; set; }
// Kategorien / Tags
public string? Category { get; set; } // Primäre Kategorie
public string? CategorySecondary { get; set; } // Sekundäre Kategorie (falls mehrere)
public bool HasMultipleCategories { get; set; }
public string? Keywords { get; set; } // HTML Meta Keywords
} }

View file

@ -175,16 +175,26 @@ function rbr_get_post_details_with_media($data) {
// Duplikate entfernen // Duplikate entfernen
$unique_media = array_values(array_unique($media_urls)); $unique_media = array_values(array_unique($media_urls));
// 4. Kategorien // 4. Kategorien - bestimme das Primäre (erste Kategorie oder nach slug sortiert)
$categories = array(); $categories = array();
$primary_category = null;
$cats = get_the_category($post_id); $cats = get_the_category($post_id);
if (!empty($cats) && !is_wp_error($cats)) { if (!empty($cats) && !is_wp_error($cats)) {
// Sortiere nach term_id (älteste/erste Kategorie wird primär)
usort($cats, function($a, $b) {
return $a->term_id <=> $b->term_id;
});
foreach ($cats as $cat) { foreach ($cats as $cat) {
$is_primary = ($cat->term_id === $cats[0]->term_id);
$categories[] = array( $categories[] = array(
'id' => intval($cat->term_id), 'id' => intval($cat->term_id),
'name' => $cat->name, 'name' => $cat->name,
'slug' => $cat->slug, 'slug' => $cat->slug,
'primary' => $is_primary,
); );
if ($is_primary) {
$primary_category = $cat->name;
}
} }
} }
@ -262,6 +272,7 @@ function rbr_get_post_details_with_media($data) {
), ),
'featured_image' => $featured_image_url, 'featured_image' => $featured_image_url,
'categories' => $categories, 'categories' => $categories,
'primary_category' => $primary_category,
'tags' => $tags, 'tags' => $tags,
'keywords' => $keywords, 'keywords' => $keywords,
'comments' => $comments, 'comments' => $comments,