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($" 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, @"<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 allTagSources = new List<WpCategory>();
@ -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 <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
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 <meta name=\"keywords\"> 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 <meta name=\"keywords\"> 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");
}

View file

@ -67,7 +67,7 @@ namespace wp2ghost
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 response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
@ -151,15 +151,22 @@ namespace wp2ghost
// ===================== Tags =====================
public static async Task<List<GhostAdminTag>> 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<GhostAdminTagsResponse>(json);
return result?.Tags ?? new();
// Browse-Endpunkte sind paginiert -> alle Seiten laden
var all = new List<GhostAdminTag>();
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<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)
@ -185,15 +192,19 @@ namespace wp2ghost
{
var result = new List<GhostAdminTag>();
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)
{
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 (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<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
{
@ -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<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
{
@ -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

View file

@ -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; }

View file

@ -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)
{

View file

@ -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<string, (string RelativePath, string LocalPath)> Cache = new();
private static Dictionary<string, string> WordpressToGhostImageName = new();
// Schlüssel: Bild-Schlüssel (siehe GetImageKey) — alle WordPress-Varianten eines Bildes teilen sich einen Eintrag
private static Dictionary<string, (string SourceUrl, string RelativePath, string LocalPath)> 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;
/// <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)
{
// 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<string>();
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)
/// <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))
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<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);
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
}
}
}
}
}

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);
// 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<string, List<string>>();
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<string>();
variants.Add(url);
}
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.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<string>();
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<string> ExtractImageUrls(string html, string wpHost)
{
var urls = new List<string>();
var imageExts = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif" };
// 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)
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) <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;
}
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<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)
{
ProcessedPosts.Add(wpId);

View file

@ -60,6 +60,16 @@ else
}
</select>
</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">
<button class="btn btn-outline-secondary me-2" @onclick="ResetFilters" title="Filter zurücksetzen">
<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.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-secondary @GetFilterClass(ImageFilter.Category)" @onclick="() => SetFilter(ImageFilter.Category)">Kategorie (@_categoryCount)</button>
</div>
</div>
<div class="col-auto">
<span class="text-muted small">
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">
(@(!string.IsNullOrWhiteSpace(_authorFilter) ? "Autor" : "")
@(!string.IsNullOrWhiteSpace(_authorFilter) && !string.IsNullOrWhiteSpace(_themeFilter) ? " + " : "")
@(!string.IsNullOrWhiteSpace(_themeFilter) ? "Thema" : ""))
</span>
<text>
@if (!string.IsNullOrWhiteSpace(_authorFilter)): @:Autor
@if (!string.IsNullOrWhiteSpace(_themeFilter)): @:Thema
@if (!string.IsNullOrWhiteSpace(_categoryFilter)): @:Kategorie
</text>
}
</span>
</div>
@ -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<GhostImage> FilteredImages => _filteredImages;
@ -538,8 +551,16 @@ else
.OrderBy(t => t)
.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 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
}
}

View file

@ -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);
});
}
}

View file

@ -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
}

View file

@ -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,