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:
parent
a730639c2a
commit
24d88e224b
10 changed files with 313 additions and 379 deletions
|
|
@ -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
|
||||
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äre Kategorie: {primary.Name}");
|
||||
}
|
||||
else
|
||||
{
|
||||
allTagSources.AddRange(postFile.Categories);
|
||||
Console.WriteLine($" -> Erste Kategorie: {postFile.Categories[0].Name}");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
// 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) return new();
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<GhostAdminTagsResponse>(json);
|
||||
return result?.Tags ?? new();
|
||||
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 & 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 <meta name="keywords"> 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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -63,10 +85,7 @@ namespace wp2ghost
|
|||
{
|
||||
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);
|
||||
|
||||
if (WordpressToGhostImageName.TryGetValue(wpImageName, out string? ghostRelativePath) && !string.IsNullOrEmpty(ghostRelativePath))
|
||||
{
|
||||
string localPath = Path.Combine(LocalImageDir, ghostRelativePath);
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
Console.WriteLine($" [Bild] Bereits heruntergeladen (Wörterbuch): {wpImageName} → {ghostRelativePath}");
|
||||
return (ghostRelativePath, localPath);
|
||||
}
|
||||
WordpressToGhostImageName.Remove(wpImageName);
|
||||
}
|
||||
|
||||
if (Cache.TryGetValue(imageUrl, out var cachedFile))
|
||||
{
|
||||
if (File.Exists(cachedFile.LocalPath))
|
||||
string key = GetImageKey(urls[0]);
|
||||
if (TryGetCached(key, out var cachedFile))
|
||||
return cachedFile;
|
||||
Cache.Remove(imageUrl);
|
||||
}
|
||||
|
||||
// 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())
|
||||
{
|
||||
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);
|
||||
byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate);
|
||||
|
||||
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}";
|
||||
var now = DateTime.Now;
|
||||
string datePath = $"{now:yyyy}/{now:MM}";
|
||||
string localDir = Path.Combine(LocalImageDir, datePath);
|
||||
if (!Directory.Exists(localDir))
|
||||
Directory.CreateDirectory(localDir);
|
||||
|
||||
// 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);
|
||||
fallBackPath = Path.Combine(LocalImageDir, fallBackPath);
|
||||
} while (File.Exists(localPath));
|
||||
|
||||
|
||||
|
||||
|
||||
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}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" [Bild] Bereits vorhanden: {relativePath}");
|
||||
}
|
||||
Console.WriteLine($" [Bild] Heruntergeladen: {Path.GetFileName(new Uri(candidate).AbsolutePath)} → {relativePath}");
|
||||
|
||||
ResizeImage(localPath);
|
||||
|
||||
var fileResult = (relativePath, localPath);
|
||||
Cache[imageUrl] = fileResult;
|
||||
WordpressToGhostImageName[wpImageName] = relativePath;
|
||||
FilesToZip.Add(fileResult);
|
||||
Cache[key] = (candidate, relativePath, localPath);
|
||||
RegisterForZip(fileResult);
|
||||
return fileResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" [Bild-Fehler] Konnte {imageUrl} nicht laden: {ex.Message}");
|
||||
lastError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
'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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue