Wp2ghost/CSharpGhostImporter/ImageManager.cs
2026-10-14 19:12:29 +02:00

336 lines
14 KiB
C#

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using System.Text.RegularExpressions;
namespace wp2ghost
{
// =============================================================================
// ImageManager — Bilder herunterladen, cachen, lokal verwalten
// =============================================================================
static class ImageManager
{
private static readonly HttpClient httpClient = 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 = "";
// Angehängte, aber im Beitrag nicht verwendete Bilder — bewusst außerhalb von images/
private const string UnusedImageDirName = "images_unbenutzt";
private static string UnusedImageDir = "";
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)
{
if (TryGetCached(GetImageKey(url), out var cached))
{
relativePath = cached.RelativePath;
return true;
}
relativePath = "";
return false;
}
private const string GhostImagePrefix = "__GHOST_URL__/content/images/";
private static readonly Dictionary<string, (int Width, int Height)?> SizeCache = new();
/// <summary>
/// Echte Maße eines lokal vorhandenen Bildes anhand seiner Ghost-URL
/// (__GHOST_URL__/content/images/...). null, wenn die URL kein lokales Bild ist.
/// </summary>
public static (int Width, int Height)? TryGetImageSize(string ghostUrl)
{
if (!ghostUrl.StartsWith(GhostImagePrefix)) return null;
string relativePath = ghostUrl.Substring(GhostImagePrefix.Length);
if (SizeCache.TryGetValue(relativePath, out var known)) return known;
(int Width, int Height)? size = null;
try
{
var info = Image.Identify(Path.Combine(LocalImageDir, relativePath));
if (info != null) size = (info.Width, info.Height);
}
catch { }
SizeCache[relativePath] = size;
return size;
}
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");
UnusedImageDir = Path.Combine(baseDir, UnusedImageDirName);
CachePath = Path.Combine(baseDir, "imagecache.txt");
if (!Directory.Exists(LocalImageDir))
Directory.CreateDirectory(LocalImageDir);
LoadCache();
}
public static string ImageContainerPath => LocalImageDir;
public static void SaveCache()
{
var lines = new List<string>();
foreach (var kvp in Cache)
lines.Add($"{kvp.Value.SourceUrl}|{kvp.Value.RelativePath}|{kvp.Value.LocalPath}|{kvp.Key}");
File.WriteAllLines(CachePath, lines);
}
public static void ClearAll()
{
if (Directory.Exists(LocalImageDir))
{
Directory.Delete(LocalImageDir, true);
Directory.CreateDirectory(LocalImageDir);
}
if (Directory.Exists(UnusedImageDir))
Directory.Delete(UnusedImageDir, true);
Cache.Clear();
FilesToZip.Clear();
SaveCache();
Console.WriteLine("Bilder wurden lokal gelöscht.");
}
public static void ShowInfo()
{
if (Cache.Count > 0)
{
Console.WriteLine("\nLokal gespeicherte Bilder:");
foreach (var kvp in Cache.Take(20))
Console.WriteLine($" {kvp.Value.SourceUrl} -> images/{kvp.Value.RelativePath}");
if (Cache.Count > 20)
Console.WriteLine($" ... und {Cache.Count - 20} weitere");
}
}
public static bool IsMainImage(string url)
{
if (string.IsNullOrEmpty(url)) return false;
try
{
var uri = new Uri(url);
string fileName = Path.GetFileName(uri.LocalPath);
// WordPress-Sizes: -300x200, -scaled, -1024x768 etc.
if (SizeSuffixRegex.IsMatch(fileName))
return false;
if (fileName.Contains("-scaled"))
return false;
return true;
}
catch { return false; }
}
/// <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)
{
var urls = CleanUrls(variants);
if (urls.Count == 0) return null;
string key = GetImageKey(urls[0]);
if (TryGetCached(key, out var cachedFile))
return cachedFile;
string lastError = "";
foreach (string candidate in GetDownloadCandidates(urls))
{
try
{
byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate);
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);
} while (File.Exists(localPath));
await File.WriteAllBytesAsync(localPath, imageBytes);
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;
}
catch (Exception ex)
{
lastError = ex.Message;
}
}
Console.WriteLine($" [Bild-Fehler] Konnte {urls[0]} nicht laden: {lastError}");
return null;
}
// Query/Fragment entfernen: ?w=1024 & Co. liefern höchstens eine verkleinerte Fassung
private static List<string> CleanUrls(IEnumerable<string> variants) => variants
.Where(u => !string.IsNullOrEmpty(u))
.Select(u => u.Split('?', '#')[0])
.Where(u => Uri.IsWellFormedUriString(u, UriKind.Absolute))
.Distinct()
.ToList();
// Reihenfolge: Original -> -scaled -> erratenes Original -> Größenvarianten (größte zuerst)
private static IEnumerable<string> GetDownloadCandidates(List<string> urls)
{
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));
return candidates.Distinct();
}
/// <summary>
/// Lädt ein am Beitrag angehängtes, aber im Beitrag nicht verwendetes Bild in den
/// Extra-Ordner (images_unbenutzt/&lt;WP-ID&gt;/). Diese Bilder werden weder nach Ghost
/// hochgeladen noch in die ZIP gepackt.
/// </summary>
public static async Task<bool> DownloadUnusedAsync(IEnumerable<string> variants, int wpId)
{
var urls = CleanUrls(variants);
if (urls.Count == 0) return false;
string dir = Path.Combine(UnusedImageDir, wpId.ToString());
string lastError = "";
foreach (string candidate in GetDownloadCandidates(urls))
{
string localPath = Path.Combine(dir, Path.GetFileName(new Uri(candidate).LocalPath));
if (File.Exists(localPath)) return true;
try
{
byte[] imageBytes = await httpClient.GetByteArrayAsync(candidate);
Directory.CreateDirectory(dir);
await File.WriteAllBytesAsync(localPath, imageBytes);
Console.WriteLine($" [Bild] Nicht im Beitrag verwendet → {UnusedImageDirName}/{wpId}/{Path.GetFileName(localPath)}");
return true;
}
catch (Exception ex)
{
lastError = ex.Message;
}
}
Console.WriteLine($" [Bild-Fehler] Konnte {urls[0]} nicht laden: {lastError}");
return false;
}
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 = 2048;
try
{
using var image = Image.Load(localPath);
int width = image.Width;
int height = image.Height;
int longEdge = Math.Max(width, height);
if (longEdge <= maxSize) return;
int newWidth, newHeight;
if (width >= height)
{
newWidth = maxSize;
newHeight = (int)Math.Round(height * ((double)maxSize / width));
}
else
{
newHeight = maxSize;
newWidth = (int)Math.Round(width * ((double)maxSize / height));
}
image.Mutate(x => x.Resize(newWidth, newHeight));
image.Save(localPath);
Console.WriteLine($" [Bild] Angepasst: {width}x{height} → {newWidth}x{newHeight}");
}
catch (Exception ex)
{
Console.WriteLine($" [Bild-Warnung] Resize fehlgeschlagen für {Path.GetFileName(localPath)}: {ex.Message}");
}
}
private static void LoadCache()
{
if (!File.Exists(CachePath)) return;
try
{
var lines = File.ReadAllLines(CachePath);
foreach (var line in lines)
{
var parts = line.Split('|');
if (parts.Length >= 3)
{
// Schlüssel immer aus der URL ableiten (ältere Cache-Dateien enthalten nur den Dateinamen)
Cache[GetImageKey(parts[0])] = (parts[0], parts[1], parts[2]);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Laden des Bild-Caches: {ex.Message}");
}
}
}
}