From 24d88e224b3f3df5d9af1917a89e1780a0d3de34 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 9 Oct 2026 12:26:18 +0200 Subject: [PATCH] =?UTF-8?q?Importer:=20Kategorien=20als=20Tags,=20Keywords?= =?UTF-8?q?=20als=20Meta,=20Bilder=20vollst=C3=A4ndig=20nach=20Ghost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- CSharpGhostImporter/Converter.cs | 57 ++-- CSharpGhostImporter/GhostApi.cs | 76 +++-- CSharpGhostImporter/GhostDto.cs | 1 + CSharpGhostImporter/HtmlConverter.cs | 5 + CSharpGhostImporter/ImageManager.cs | 221 ++++++++------- CSharpGhostImporter/PostFetcher.cs | 264 +++--------------- .../Components/Pages/Images.razor | 40 ++- GhostImageManager/Data/AppDbContext.cs | 3 + GhostImageManager/Data/ManagedImage.cs | 6 + WPPlugin/rbrExportRestApi.php | 19 +- 10 files changed, 313 insertions(+), 379 deletions(-) diff --git a/CSharpGhostImporter/Converter.cs b/CSharpGhostImporter/Converter.cs index 7c70bc2..7d6a959 100644 --- a/CSharpGhostImporter/Converter.cs +++ b/CSharpGhostImporter/Converter.cs @@ -748,7 +748,7 @@ 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($" Schlagwörter: {postFile.Tags.Count}"); Console.WriteLine($" Kommentare: {postFile.Comments.Count}"); Console.WriteLine($" Bilder: {postFile.ImageFiles.Count}"); @@ -813,7 +813,13 @@ class Program 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, @"]*?\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(); var allTagSources = new List(); @@ -826,36 +832,41 @@ class Program { 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) + + int catIdx; + while (true) { - // 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}"); + Console.Write(" Welche ist die primäre Kategorie? (Nummer): "); + string? catChoice = Console.ReadLine(); + if (int.TryParse(catChoice?.Trim(), out catIdx) && catIdx >= 1 && catIdx <= postFile.Categories.Count) + break; + if (catChoice == null) { catIdx = 1; break; } // keine Eingabe möglich (stdin geschlossen) + Console.WriteLine($" Bitte eine Zahl von 1 bis {postFile.Categories.Count} eingeben."); } + + // 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) { 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..."); + Console.WriteLine($" [Tags] Verarbeite {allTagSources.Count} Kategorien..."); tags = await GhostApi.EnsureTagsAsync(allTagSources); } + // 4b. Schlagwörter (WP-Tags + SEO-Keywords) ohne Nachfrage als + 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 Console.Write(" Prüfe Ghost..."); var existingPosts = await GhostApi.BrowsePostsAsync($"slug:{postFile.Slug}", 1); @@ -865,21 +876,25 @@ class Program if (existing != null) { Console.WriteLine($" vorhanden (ID: {existing.Id})"); + string? head = GhostApi.BuildKeywordsHead(existing.CodeinjectionHead, keywords); + if (head != null) Console.WriteLine(" [Meta] Schlagwörter als gesetzt"); Console.Write(" Aktualisiere..."); result = await GhostApi.UpdatePostExtendedAsync( existing.Id!, postFile.Title ?? "", html, existing.UpdatedAt ?? "", postFile.Status ?? "published", - featureImageUrl, tags, postFile.Keywords); + featureImageUrl, tags, head); Console.WriteLine(result != null ? " OK" : " FEHLER"); } else { Console.WriteLine(" nicht vorhanden"); + string? head = GhostApi.BuildKeywordsHead(null, keywords); + if (head != null) Console.WriteLine(" [Meta] Schlagwörter als gesetzt"); Console.Write(" Erstelle neuen Post..."); result = await GhostApi.CreatePostExtendedAsync( postFile.Title ?? "", html, postFile.Status ?? "published", postFile.Slug, - featureImageUrl, tags, postFile.Keywords); + featureImageUrl, tags, head); Console.WriteLine(result != null ? $" OK (ID: {result.Id})" : " FEHLER"); } diff --git a/CSharpGhostImporter/GhostApi.cs b/CSharpGhostImporter/GhostApi.cs index 0b909b2..92ebecb 100644 --- a/CSharpGhostImporter/GhostApi.cs +++ b/CSharpGhostImporter/GhostApi.cs @@ -67,7 +67,7 @@ namespace wp2ghost public static async Task> 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 response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); @@ -151,15 +151,22 @@ namespace wp2ghost // ===================== Tags ===================== public static async Task> BrowseTagsAsync(string filter = "", int limit = 100) { - string endpoint = $"tags/?limit={limit}&fields=id,name,slug"; - if (!string.IsNullOrEmpty(filter)) - endpoint += $"&filter={Uri.EscapeDataString(filter)}"; - var request = await CreateRequestAsync(HttpMethod.Get, endpoint); - var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) return new(); - var json = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(json); - return result?.Tags ?? new(); + // Browse-Endpunkte sind paginiert -> alle Seiten laden + var all = new List(); + for (int page = 1; ; page++) + { + string endpoint = $"tags/?limit={limit}&page={page}&fields=id,name,slug"; + if (!string.IsNullOrEmpty(filter)) + endpoint += $"&filter={Uri.EscapeDataString(filter)}"; + var request = await CreateRequestAsync(HttpMethod.Get, endpoint); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) break; + var json = await response.Content.ReadAsStringAsync(); + var tags = JsonSerializer.Deserialize(json)?.Tags ?? new(); + all.AddRange(tags); + if (tags.Count < limit) break; + } + return all; } public static async Task CreateTagAsync(string name, string? slug = null) @@ -185,15 +192,19 @@ namespace wp2ghost { var result = new List(); var existingTags = await BrowseTagsAsync(); - var existingByName = existingTags.Where(t => t.Name != null).ToDictionary(t => t.Name!.ToLowerInvariant(), t => t); + var existingByName = new Dictionary(); + foreach (var t in existingTags.Where(t => t.Name != null)) + existingByName.TryAdd(t.Name!.ToLowerInvariant(), t); foreach (var cat in categories) { - string name = cat.Name ?? ""; + // WordPress liefert Namen HTML-kodiert (z.B. "Tipps & Tricks") + string name = System.Net.WebUtility.HtmlDecode(cat.Name ?? "").Trim(); if (string.IsNullOrEmpty(name)) continue; 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 { @@ -203,6 +214,7 @@ namespace wp2ghost { Console.WriteLine(" OK"); result.Add(newTag); + existingByName[name.ToLowerInvariant()] = newTag; } else Console.WriteLine(" FEHLER"); @@ -262,7 +274,30 @@ namespace wp2ghost } // ===================== Posts (erweitert) ===================== - public static async Task CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List tags, string? metaKeywords = null) + private static readonly System.Text.RegularExpressions.Regex MetaKeywordsRegex = new( + @"]*>\s*", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + /// + /// Ghost kennt kein meta_keywords-Feld -> Keywords als <meta name="keywords"> in den + /// Code-Injection-Header des Posts schreiben. Vorhandener Header-Code bleibt erhalten, + /// ein bereits vorhandenes Keywords-Meta wird ersetzt. + /// + public static string? BuildKeywordsHead(string? existingHead, IEnumerable 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 = $""; + return rest.Length > 0 ? rest + "\n" + meta : meta; + } + + public static async Task CreatePostExtendedAsync(string title, string html, string status, string? slug, string? featureImage, List tags, string? codeinjectionHead = null) { var post = new GhostAdminPostEdit { @@ -270,7 +305,7 @@ namespace wp2ghost Html = html, Status = status, FeatureImage = featureImage, - MetaKeywords = metaKeywords + CodeinjectionHead = codeinjectionHead }; if (slug != null) post.Slug = slug; 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(); } - public static async Task UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List tags, string? metaKeywords = null) + public static async Task UpdatePostExtendedAsync(string postId, string title, string html, string updatedAt, string status, string? featureImage, List tags, string? codeinjectionHead = null) { var post = new GhostAdminPostEdit { @@ -300,7 +335,7 @@ namespace wp2ghost Status = status, UpdatedAt = updatedAt, FeatureImage = featureImage, - MetaKeywords = metaKeywords + CodeinjectionHead = codeinjectionHead }; 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")] public bool Featured { get; set; } + + [JsonPropertyName("codeinjection_head")] + public string? CodeinjectionHead { get; set; } } public class GhostAdminPostEdit @@ -566,9 +604,9 @@ namespace wp2ghost [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? MetaDescription { get; set; } - [JsonPropertyName("meta_keywords")] + [JsonPropertyName("codeinjection_head")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? MetaKeywords { get; set; } + public string? CodeinjectionHead { get; set; } } public class GhostImageUploadResponse diff --git a/CSharpGhostImporter/GhostDto.cs b/CSharpGhostImporter/GhostDto.cs index 1e6aac8..30aeb39 100644 --- a/CSharpGhostImporter/GhostDto.cs +++ b/CSharpGhostImporter/GhostDto.cs @@ -13,6 +13,7 @@ namespace wp2ghost [JsonPropertyName("slug")] public string? Slug { get; set; } [JsonPropertyName("html")] public string? Html { 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("created_at")] public string? CreatedAt { get; set; } [JsonPropertyName("published_at")] public string? PublishedAt { get; set; } diff --git a/CSharpGhostImporter/HtmlConverter.cs b/CSharpGhostImporter/HtmlConverter.cs index 5b2268d..75cb189 100644 --- a/CSharpGhostImporter/HtmlConverter.cs +++ b/CSharpGhostImporter/HtmlConverter.cs @@ -503,6 +503,11 @@ namespace wp2ghost string src = GetBestImageUrl(img); 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(); if (parentFigure != null) { diff --git a/CSharpGhostImporter/ImageManager.cs b/CSharpGhostImporter/ImageManager.cs index 861e608..e4a454e 100644 --- a/CSharpGhostImporter/ImageManager.cs +++ b/CSharpGhostImporter/ImageManager.cs @@ -1,6 +1,6 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; -using System.Diagnostics.Metrics; +using System.Text.RegularExpressions; namespace wp2ghost { @@ -10,42 +10,64 @@ namespace wp2ghost static class ImageManager { private static readonly HttpClient httpClient = new(); - private static Dictionary Cache = new(); - private static Dictionary WordpressToGhostImageName = new(); + // Schlüssel: Bild-Schlüssel (siehe GetImageKey) — alle WordPress-Varianten eines Bildes teilen sich einen Eintrag + private static Dictionary Cache = new(); private static List<(string RelativePath, string LocalPath)> FilesToZip = new(); private static string LocalImageDir = ""; 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; + + /// + /// Schlüssel, unter dem alle Varianten desselben WordPress-Bildes zusammenfallen: + /// Pfad ohne Host, Query (?w=1024), Größen-Suffix (-1024x683) und -scaled. + /// + 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) { - // 1. Direkte URL-Prüfung im Cache - if (Cache.TryGetValue(url, out var cached)) + if (TryGetCached(GetImageKey(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; } + 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) { LocalImageDir = Path.Combine(baseDir, "images"); @@ -57,16 +79,13 @@ namespace wp2ghost LoadCache(); } - public static string ImageContainerPath => LocalImageDir; + public static string ImageContainerPath => LocalImageDir; public static void SaveCache() { var lines = new List(); foreach (var kvp in Cache) - { - string wpName = Path.GetFileNameWithoutExtension(new Uri(kvp.Key).AbsolutePath); - lines.Add($"{kvp.Key}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{wpName}"); - } + lines.Add($"{kvp.Value.SourceUrl}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{kvp.Key}"); File.WriteAllLines(CachePath, lines); } @@ -89,7 +108,7 @@ namespace wp2ghost { Console.WriteLine("\nLokal gespeicherte Bilder:"); 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) Console.WriteLine($" ... und {Cache.Count - 20} weitere"); } @@ -103,7 +122,7 @@ namespace wp2ghost var uri = new Uri(url); string fileName = Path.GetFileName(uri.LocalPath); // WordPress-Sizes: -300x200, -scaled, -1024x768 etc. - if (System.Text.RegularExpressions.Regex.IsMatch(fileName, @"-\d+x\d+(?=\.\w+$)")) + if (SizeSuffixRegex.IsMatch(fileName)) return false; if (fileName.Contains("-scaled")) return false; @@ -112,86 +131,85 @@ namespace wp2ghost catch { return false; } } - public static async Task<(string RelativePath, string LocalPath)?> DownloadAsync(string imageUrl, DateTime? postDate) + /// + /// 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. + /// + public static async Task<(string RelativePath, string LocalPath)?> DownloadBestAsync(IEnumerable variants, DateTime? postDate) { - if (string.IsNullOrEmpty(imageUrl) || !Uri.IsWellFormedUriString(imageUrl, UriKind.Absolute)) - return null; + // Query/Fragment entfernen: ?w=1024 & Co. liefern höchstens eine verkleinerte Fassung + 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(); + 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); - if (File.Exists(localPath)) + try { - Console.WriteLine($" [Bild] Bereits heruntergeladen (Wörterbuch): {wpImageName} → {ghostRelativePath}"); - return (ghostRelativePath, localPath); - } - WordpressToGhostImageName.Remove(wpImageName); - } + byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate); - if (Cache.TryGetValue(imageUrl, out var cachedFile)) - { - if (File.Exists(cachedFile.LocalPath)) - return cachedFile; - Cache.Remove(imageUrl); - } + var now = DateTime.Now; + string datePath = $"{now:yyyy}/{now:MM}"; + string localDir = Path.Combine(LocalImageDir, datePath); + if (!Directory.Exists(localDir)) + Directory.CreateDirectory(localDir); - try - { - string fileName = Path.GetFileName(new Uri(imageUrl).AbsolutePath); - string urlHash = GetHashCode(imageUrl); - var datePrefix = DateTime.Now; - string datePath = $"{datePrefix:yyyy}/{datePrefix:MM}"; - var localPath = Path.Combine(LocalImageDir, datePath); - if (!string.IsNullOrEmpty(localPath) && !Directory.Exists(localPath)) - Directory.CreateDirectory(localPath); + // Eindeutigen Dateinamen bestimmen (nie eine vorhandene Datei wiederverwenden) + var pDate = postDate ?? now; + string ext = Path.GetExtension(new Uri(candidate).AbsolutePath); + int counter = 1 + new DirectoryInfo(localDir).GetFiles().Length; + string relativePath, localPath; + do + { + relativePath = $"{datePath}/{pDate:yyyyMMdd}_{counter++}{ext}"; + 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); - 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) { - const int maxSize = 1024; + const int maxSize = 2048; try { 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() { if (!File.Exists(CachePath)) return; @@ -245,10 +252,8 @@ namespace wp2ghost var parts = line.Split('|'); 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]; + // Schlüssel immer aus der URL ableiten (ältere Cache-Dateien enthalten nur den Dateinamen) + Cache[GetImageKey(parts[0])] = (parts[0], parts[1], parts[2]); } } } @@ -258,4 +263,4 @@ namespace wp2ghost } } } -} \ No newline at end of file +} diff --git a/CSharpGhostImporter/PostFetcher.cs b/CSharpGhostImporter/PostFetcher.cs index c24d087..267a524 100644 --- a/CSharpGhostImporter/PostFetcher.cs +++ b/CSharpGhostImporter/PostFetcher.cs @@ -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); - // Bevorzugte Media-Dateien ermitteln - var preferredMedia = GetPreferredMediaFiles(wpPost.MediaFiles, htmlImageUrls, wpHost); - - // Prüfe: Referenzierte Bilder im HTML, die NICHT in MediaFiles sind - if (htmlImageUrls.Count > 0 && wpPost.MediaFiles != null) + // Varianten desselben Bildes (Original, -scaled, -1024x683, ?w=...) zusammenfassen: + // pro Bild wird genau EINE Datei geladen, auf die alle Varianten umgeschrieben werden. + var imageGroups = new Dictionary>(); + var mediaUrls = (wpPost.MediaFiles ?? new()).Where(u => IsInternalUrl(u, wpHost)).ToList(); + 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(); - foreach (var imgUrl in htmlImageUrls) - { - string imgFileName = GetFileNameFromUrl(imgUrl).ToLowerInvariant(); - if (!mediaFileNames.Contains(imgFileName)) - { - Console.WriteLine($" [Warnung] Bild im HTML nicht in MediaFiles: {Path.GetFileName(new Uri(imgUrl).AbsolutePath)}"); - } - } + string key = ImageManager.GetImageKey(url); + if (!imageGroups.TryGetValue(key, out var variants)) + imageGroups[key] = variants = new List(); + variants.Add(url); } - if (preferredMedia.Count > 0) + var imageFiles = new List(); + var ghostPathByKey = new Dictionary(); + foreach (var group in imageGroups) { - foreach (string mediaUrl in preferredMedia) - { - var downloaded = await ImageManager.DownloadAsync(mediaUrl, postDate); - if (downloaded.HasValue) - { - string ghostMediaPath = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}"; - htmlContent = htmlContent.Replace(mediaUrl, ghostMediaPath); - } - } + var downloaded = await ImageManager.DownloadBestAsync(group.Value, postDate); + if (!downloaded.HasValue) continue; + ghostPathByKey[group.Key] = $"__GHOST_URL__/content/images/{downloaded.Value.RelativePath}"; + if (!imageFiles.Contains(downloaded.Value.RelativePath)) + imageFiles.Add(downloaded.Value.RelativePath); + } + + // 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.) htmlContent = HtmlGhostConverter.Convert(htmlContent); - // Lokale Bildpfade für JSON-Datei sammeln - var imageFiles = new List(); - if (preferredMedia.Count > 0) - { - foreach (string mediaUrl in preferredMedia) - { - if (ImageManager.TryGetRelativePath(mediaUrl, out string relPath)) - imageFiles.Add(relPath); - } - } + int remainingWpImages = ExtractImageUrls(htmlContent, wpHost).Count; + if (remainingWpImages > 0) + Console.WriteLine($" [Warnung] {remainingWpImages} Bild-URL(s) zeigen weiterhin auf WordPress"); - // Featured Image verarbeiten + // Featured Image (wurde oben mit heruntergeladen) 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 = $"__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); - } - } + featureImageGhostPath = featPath; } var ghostPost = new GhostPost @@ -216,6 +199,7 @@ namespace wp2ghost Title = wpPost.Title, Slug = wpPost.Slug, Html = htmlContent, + FeatureImage = string.IsNullOrEmpty(featureImageGhostPath) ? null : featureImageGhostPath, Status = "published", CreatedAt = postDate.ToString("o"), PublishedAt = postDate.ToString("o") @@ -285,83 +269,24 @@ namespace wp2ghost // 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 ExtractImageUrls(string html, string wpHost) { var urls = new List(); - var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" }; - - // 1) extrahieren - var imgMatches = System.Text.RegularExpressions.Regex.Matches(html, @"]*src=[""']([^""']+)[""']", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - foreach (System.Text.RegularExpressions.Match m in imgMatches) + foreach (System.Text.RegularExpressions.Match m in ImageUrlRegex.Matches(html)) { - string url = m.Groups[1].Value; - if (!string.IsNullOrEmpty(url) && IsInternalUrl(url, wpHost) && !urls.Contains(url)) + string url = m.Value; + if (IsInternalUrl(url, wpHost) && !urls.Contains(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) mit Bildendung (Figure-Links auf Originalbild) - var aMatches = System.Text.RegularExpressions.Regex.Matches(html, @"]*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; } - 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) { 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 GetPreferredMediaFiles(List? mediaFiles, List htmlImageUrls, string wpHost) - { - var result = new List(); - 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(); - 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>(); - foreach (var url in allUrls) - { - string normalized = NormalizeImageUrl(url); - if (!grouped.ContainsKey(normalized)) - grouped[normalized] = new List(); - 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 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) { ProcessedPosts.Add(wpId); diff --git a/GhostImageManager/Components/Pages/Images.razor b/GhostImageManager/Components/Pages/Images.razor index 9c764ee..925d29d 100644 --- a/GhostImageManager/Components/Pages/Images.razor +++ b/GhostImageManager/Components/Pages/Images.razor @@ -60,6 +60,16 @@ else } +
+ + +
+
Gefiltert: @_filteredImages.Count von @_allImages.Count - @if (!string.IsNullOrWhiteSpace(_authorFilter) || !string.IsNullOrWhiteSpace(_themeFilter)) + @if (!string.IsNullOrWhiteSpace(_authorFilter) || !string.IsNullOrWhiteSpace(_themeFilter) || !string.IsNullOrWhiteSpace(_categoryFilter)) { - - (@(!string.IsNullOrWhiteSpace(_authorFilter) ? "Autor" : "") - @(!string.IsNullOrWhiteSpace(_authorFilter) && !string.IsNullOrWhiteSpace(_themeFilter) ? " + " : "") - @(!string.IsNullOrWhiteSpace(_themeFilter) ? "Thema" : "")) - + + @if (!string.IsNullOrWhiteSpace(_authorFilter)): @:Autor + @if (!string.IsNullOrWhiteSpace(_themeFilter)): @:Thema + @if (!string.IsNullOrWhiteSpace(_categoryFilter)): @:Kategorie + }
@@ -437,6 +448,7 @@ else private int _orphanedCount; private int _featureCount; private int _contentCount; + private string _categoryFilter = ""; private GhostImage? _selectedImage; private bool _showReplaceDialog; @@ -518,6 +530,7 @@ else _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)); + _categoryCount = _allImages.Count(i => !string.IsNullOrEmpty(i.Category)); } private List FilteredImages => _filteredImages; @@ -538,8 +551,16 @@ else .OrderBy(t => t) .ToList(); + private List> AvailableCategories => _allImages + .Where(i => !string.IsNullOrEmpty(i.Category)) + .GroupBy(i => i.Category!) + .Select(g => new KeyValuePair(g.Key, g.Key)) + .OrderBy(kvp => kvp.Value) + .ToList(); + private int GetAuthorCount(string authorId) => _allImages.Count(i => i.AuthorId == authorId); 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() { @@ -558,6 +579,9 @@ else if (!string.IsNullOrWhiteSpace(_themeFilter)) query = query.Where(i => i.Theme == _themeFilter); + if (!string.IsNullOrWhiteSpace(_categoryFilter)) + query = query.Where(i => i.Category == _categoryFilter); + _filteredImages = query.ToList(); StateHasChanged(); } @@ -566,6 +590,7 @@ else { _authorFilter = ""; _themeFilter = ""; + _categoryFilter = ""; _searchTerm = ""; _currentFilter = ImageFilter.All; ApplyFilters(); @@ -858,6 +883,7 @@ else All, Feature, Content, - Orphaned + Orphaned, + Category } } diff --git a/GhostImageManager/Data/AppDbContext.cs b/GhostImageManager/Data/AppDbContext.cs index 07b8a97..9f5ed1a 100644 --- a/GhostImageManager/Data/AppDbContext.cs +++ b/GhostImageManager/Data/AppDbContext.cs @@ -36,6 +36,9 @@ public class AppDbContext : DbContext entity.Property(e => e.Iso).HasMaxLength(50); entity.Property(e => e.FocalLength).HasMaxLength(50); 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); }); } } diff --git a/GhostImageManager/Data/ManagedImage.cs b/GhostImageManager/Data/ManagedImage.cs index 057d2f3..f869acb 100644 --- a/GhostImageManager/Data/ManagedImage.cs +++ b/GhostImageManager/Data/ManagedImage.cs @@ -41,4 +41,10 @@ public class ManagedImage public DateTime FirstSeen { get; set; } public DateTime LastUpdated { 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 } diff --git a/WPPlugin/rbrExportRestApi.php b/WPPlugin/rbrExportRestApi.php index 970755d..d6beec6 100644 --- a/WPPlugin/rbrExportRestApi.php +++ b/WPPlugin/rbrExportRestApi.php @@ -175,16 +175,26 @@ function rbr_get_post_details_with_media($data) { // Duplikate entfernen $unique_media = array_values(array_unique($media_urls)); - // 4. Kategorien + // 4. Kategorien - bestimme das Primäre (erste Kategorie oder nach slug sortiert) $categories = array(); + $primary_category = null; $cats = get_the_category($post_id); 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) { + $is_primary = ($cat->term_id === $cats[0]->term_id); $categories[] = array( - 'id' => intval($cat->term_id), - 'name' => $cat->name, - 'slug' => $cat->slug, + 'id' => intval($cat->term_id), + 'name' => $cat->name, + '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, 'categories' => $categories, + 'primary_category' => $primary_category, 'tags' => $tags, 'keywords' => $keywords, 'comments' => $comments,