Wp2ghost/GhostImageManager/Services/ImageAnalyzerService.cs

339 lines
14 KiB
C#
Raw Normal View History

2026-09-24 19:23:54 +02:00
using System.Text.RegularExpressions;
2026-09-28 19:09:09 +02:00
using GhostImageManager.Data;
2026-09-24 19:23:54 +02:00
using GhostImageManager.Models;
using HtmlAgilityPack;
namespace GhostImageManager.Services;
public class ImageAnalyzerService
{
private readonly GhostApiService _ghostApiService;
2026-09-28 19:09:09 +02:00
private readonly ExifService _exifService;
private readonly ImageDatabaseService _dbService;
2026-09-24 19:23:54 +02:00
private readonly ILogger<ImageAnalyzerService> _logger;
2026-09-28 19:09:09 +02:00
public ImageAnalyzerService(
GhostApiService ghostApiService,
ExifService exifService,
ImageDatabaseService dbService,
ILogger<ImageAnalyzerService> logger)
2026-09-24 19:23:54 +02:00
{
_ghostApiService = ghostApiService;
2026-09-28 19:09:09 +02:00
_exifService = exifService;
_dbService = dbService;
2026-09-24 19:23:54 +02:00
_logger = logger;
}
2026-10-14 19:12:29 +02:00
// Hinweis der letzten Analyse (z.B. Bildordner passt nicht zur Ghost-Instanz)
public string? LastWarning { get; private set; }
// Findet Bildpfade unterhalb von /content/images/ in HTML, Lexical-JSON und API-Feldern.
// Größen-Varianten (size/w600/..., size/w600/format/webp/...) werden auf das Ursprungsbild zurückgeführt.
private static readonly Regex ContentImageRegex = new(
@"content\\?/images\\?/(?:size\\?/w\d+\\?/(?:format\\?/\w+\\?/)?)?(?<path>[^""'\s\\()<>?#,]+(?:\\?/[^""'\s\\()<>?#,]+)*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance)
2026-09-24 19:23:54 +02:00
{
var images = new List<GhostImage>();
2026-10-14 19:12:29 +02:00
LastWarning = null;
2026-09-28 19:09:09 +02:00
_logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath);
2026-09-24 19:23:54 +02:00
if (!Directory.Exists(instance.ImageFolderPath))
{
2026-09-28 19:09:09 +02:00
_logger.LogError("Image folder not found: {Path}", instance.ImageFolderPath);
2026-10-14 19:12:29 +02:00
LastWarning = $"Bildordner nicht gefunden: {instance.ImageFolderPath}";
2026-09-24 19:23:54 +02:00
return images;
}
2026-10-14 19:12:29 +02:00
// 1. Immer ALLE Posts und Seiten abrufen – sonst gelten Bilder aus nicht geladenen Posts als verwaist
var posts = await _ghostApiService.GetPostsAsync(instance);
posts.AddRange(await _ghostApiService.GetPostsAsync(instance, resource: "pages"));
_logger.LogInformation("{Count} Posts/Seiten geladen, extrahiere Bild-Referenzen", posts.Count);
2026-09-24 19:23:54 +02:00
2026-10-14 19:12:29 +02:00
// 2. Bild-Referenzen sammeln. Schlüssel: relativer Pfad unter content/images (Ghost-Bilder)
// bzw. die vollständige URL (externe Bilder)
2026-09-24 19:23:54 +02:00
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
2026-10-14 19:12:29 +02:00
PostReference GetReference(string key, GhostPost post)
{
if (!imageReferences.TryGetValue(key, out var list))
imageReferences[key] = list = new List<PostReference>();
var reference = list.FirstOrDefault(r => r.PostId == post.Id);
if (reference == null)
{
reference = new PostReference { PostId = post.Id, Title = post.Title, Slug = post.Slug };
list.Add(reference);
}
return reference;
}
2026-09-24 19:23:54 +02:00
foreach (var post in posts)
{
// Feature Image
if (!string.IsNullOrEmpty(post.FeatureImage))
2026-10-14 19:12:29 +02:00
GetReference(GetReferenceKey(post.FeatureImage), post).IsFeatureImage = true;
2026-09-24 19:23:54 +02:00
2026-10-14 19:12:29 +02:00
// Ghost-Bilder im Inhalt: HTML (src, srcset, Hintergrundbilder), Lexical (Galerien, Karten), og/twitter
foreach (var path in ExtractContentImagePaths($"{post.Html}\n{post.Lexical}\n{post.OtherImageFields}"))
GetReference(path, post).IsInContent = true;
2026-09-24 19:23:54 +02:00
2026-10-14 19:12:29 +02:00
// Externe Bilder im HTML
foreach (var imgUrl in ExtractImageUrls(post.Html))
2026-09-24 19:23:54 +02:00
{
2026-10-14 19:12:29 +02:00
if (!ContentImageRegex.IsMatch(imgUrl))
GetReference(imgUrl, post).IsInContent = true;
2026-09-24 19:23:54 +02:00
}
}
2026-10-14 19:12:29 +02:00
// Tags, Benutzer und Website-Einstellungen (Logo, Icon, Cover ...)
foreach (var (label, json) in await _ghostApiService.GetOtherImageSourcesAsync(instance))
{
var source = new GhostPost { Id = label, Title = label };
foreach (var path in ExtractContentImagePaths(json))
GetReference(path, source);
}
2026-09-24 19:23:54 +02:00
// 3. Lokale Dateien scannen
2026-09-28 19:09:09 +02:00
_logger.LogInformation("Scanne lokalen Image-Ordner: {Folder}", instance.ImageFolderPath);
2026-09-24 19:23:54 +02:00
var localFiles = ScanLocalImageFiles(instance.ImageFolderPath);
2026-09-28 19:09:09 +02:00
_logger.LogInformation("{Count} lokale Bilddateien gefunden", localFiles.Count);
2026-09-24 19:23:54 +02:00
2026-10-14 19:12:29 +02:00
var localPaths = localFiles
.Select(f => GetRelativePath(instance.ImageFolderPath, f.FullName))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var ghostReferenceCount = imageReferences.Keys.Count(k => !IsExternalUrl(k));
if (ghostReferenceCount > 0 && localFiles.Count > 0 && !imageReferences.Keys.Any(localPaths.Contains))
{
LastWarning = $"Keines der {ghostReferenceCount} in Ghost verwendeten Bilder liegt im Ordner {instance.ImageFolderPath}. " +
"Der Ordner ist vermutlich nicht (mehr) der content/images-Ordner dieser Ghost-Instanz – alle lokalen Dateien erscheinen deshalb als verwaist.";
_logger.LogWarning("{Warning}", LastWarning);
}
2026-09-28 19:09:09 +02:00
// 4. GhostImage-Objekte erstellen und mit DB synchronisieren
2026-09-24 19:23:54 +02:00
foreach (var file in localFiles)
{
var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName);
2026-09-28 19:09:09 +02:00
var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName);
2026-10-14 19:12:29 +02:00
imageReferences.TryGetValue(relativePath, out var refs);
2026-09-24 19:23:54 +02:00
var image = new GhostImage
{
FilePath = file.FullName,
FileName = file.Name,
RelativePath = relativePath,
Url = absoluteUrl,
FileSize = file.Length,
LastModified = file.LastWriteTime,
2026-10-14 19:12:29 +02:00
UsedInPosts = refs ?? new List<PostReference>(),
IsOrphaned = refs == null
2026-09-24 19:23:54 +02:00
};
2026-10-14 19:12:29 +02:00
// "bild_o.jpg" ist Ghosts Originalkopie von "bild.jpg": zählt als verwendet, solange das Bild verwendet wird
var basePath = GetOriginalBasePath(relativePath);
if (refs == null && basePath != null && localPaths.Contains(basePath))
{
image.IsOriginal = true;
image.OriginalOf = Path.GetFileName(basePath);
image.IsOrphaned = !imageReferences.ContainsKey(basePath);
}
2026-09-24 19:23:54 +02:00
// Dimensionen ermitteln
GetImageDimensions(image);
2026-09-28 19:09:09 +02:00
// EXIF-Daten extrahieren
ExifData exifData = null!;
if (!file.Extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
exifData = _exifService.ExtractExifData(file.FullName);
ApplyExifData(image, exifData);
}
// Mit Datenbank synchronisieren
var managedImage = await _dbService.SyncImageAsync(image, instance.Id, exifData);
ApplyDatabaseData(image, managedImage);
2026-09-24 19:23:54 +02:00
images.Add(image);
}
// Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder)
2026-10-14 19:12:29 +02:00
foreach (var key in imageReferences.Keys)
2026-09-24 19:23:54 +02:00
{
2026-10-14 19:12:29 +02:00
if (localPaths.Contains(key))
continue;
var url = IsExternalUrl(key) ? key : $"{instance.Url.TrimEnd('/')}/content/images/{key}";
2026-09-24 19:23:54 +02:00
var localPath = ConvertUrlToLocalPath(instance, url);
2026-10-14 19:12:29 +02:00
var externalImage = new GhostImage
2026-09-24 19:23:54 +02:00
{
2026-10-14 19:12:29 +02:00
Url = url,
FileName = Path.GetFileName(url),
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
FilePath = localPath,
UsedInPosts = imageReferences[key],
IsOrphaned = false
};
var managedImage = await _dbService.SyncImageAsync(externalImage, instance.Id, null);
ApplyDatabaseData(externalImage, managedImage);
images.Add(externalImage);
2026-09-24 19:23:54 +02:00
}
2026-09-28 19:09:09 +02:00
var orphanedCount = images.Count(i => i.IsOrphaned);
_logger.LogInformation("Bildanalyse abgeschlossen: {Total} Bilder, {Orphaned} verwaist, {Used} verwendet", images.Count, orphanedCount, images.Count - orphanedCount);
2026-09-24 19:23:54 +02:00
return images;
}
2026-09-28 19:09:09 +02:00
private void ApplyExifData(GhostImage image, ExifData exifData)
{
image.CameraMake = exifData.CameraMake;
image.CameraModel = exifData.CameraModel;
image.Lens = exifData.Lens;
image.Aperture = exifData.Aperture;
image.ShutterSpeed = exifData.ShutterSpeed;
image.Iso = exifData.Iso;
image.FocalLength = exifData.FocalLength;
image.CaptureDate = exifData.CaptureDate;
}
private void ApplyDatabaseData(GhostImage image, ManagedImage managedImage)
{
image.ManagedImageId = managedImage.Id;
image.AuthorId = managedImage.AuthorId;
image.AuthorName = managedImage.AuthorName;
image.Theme = managedImage.Theme;
2026-10-14 19:12:29 +02:00
image.Category = managedImage.Category;
2026-09-28 19:09:09 +02:00
// CaptureDate aus DB hat Priorität, falls manuell überschrieben
if (managedImage.CaptureDate.HasValue)
image.CaptureDate = managedImage.CaptureDate.Value;
}
2026-09-24 19:23:54 +02:00
private List<FileInfo> ScanLocalImageFiles(string folderPath)
{
var files = new List<FileInfo>();
var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" };
if (!Directory.Exists(folderPath))
return files;
2026-09-28 19:09:09 +02:00
2026-09-24 19:23:54 +02:00
foreach (var ext in extensions)
{
files.AddRange(new DirectoryInfo(folderPath)
.EnumerateFiles($"*{ext}", SearchOption.AllDirectories));
}
2026-09-28 19:09:09 +02:00
var excludedDirs = Path.Combine(folderPath, "size");
return files.Where(f => f.DirectoryName.StartsWith(excludedDirs) == false).OrderBy(f => f.FullName).ToList();
2026-09-24 19:23:54 +02:00
}
private List<string> ExtractImageUrls(string html)
{
var urls = new List<string>();
try
{
var doc = new HtmlDocument();
doc.LoadHtml(html);
var imgTags = doc.DocumentNode.SelectNodes("//img[@src]");
if (imgTags != null)
{
foreach (var img in imgTags)
{
var src = img.GetAttributeValue("src", "").Trim();
if (!string.IsNullOrEmpty(src))
urls.Add(src);
}
}
}
catch (Exception ex)
{
2026-09-28 19:09:09 +02:00
_logger.LogError(ex, "Fehler beim Parsen von HTML nach Bild-URLs");
2026-09-24 19:23:54 +02:00
}
return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
2026-10-14 19:12:29 +02:00
private static List<string> ExtractContentImagePaths(string text)
{
return ContentImageRegex.Matches(text)
.Select(m => Uri.UnescapeDataString(m.Groups["path"].Value.Replace("\\/", "/")))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
// Ghost-Bild -> relativer Pfad, alles andere -> URL unverändert
private static string GetReferenceKey(string url)
{
return ExtractContentImagePaths(url).FirstOrDefault() ?? url;
}
private static bool IsExternalUrl(string key)
{
return key.Contains("://") || key.StartsWith("//") || key.StartsWith("data:", StringComparison.OrdinalIgnoreCase);
}
// "2026/09/bild_o.jpg" -> "2026/09/bild.jpg"; null, wenn der Name nicht auf _o endet
private static string? GetOriginalBasePath(string relativePath)
{
var extension = Path.GetExtension(relativePath);
var withoutExtension = relativePath[..^extension.Length];
return withoutExtension.EndsWith("_o", StringComparison.OrdinalIgnoreCase)
? withoutExtension[..^2] + extension
: null;
}
2026-09-24 19:23:54 +02:00
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath)
{
2026-09-28 19:09:09 +02:00
var relativePath = GetRelativePath(instance.ImageFolderPath, localPath);
2026-09-24 19:23:54 +02:00
return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}";
}
private string ConvertUrlToLocalPath(GhostInstance instance, string url)
{
var contentImagesUrl = $"{instance.Url.TrimEnd('/')}/content/images/";
if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase))
{
var relativePath = url[contentImagesUrl.Length..];
2026-09-28 19:09:09 +02:00
return Path.Combine(instance.ImageFolderPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
2026-09-24 19:23:54 +02:00
}
return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url));
}
private string GetRelativePath(string basePath, string targetPath)
{
2026-09-28 19:09:09 +02:00
if (!basePath.EndsWith(Path.DirectorySeparatorChar) && !basePath.EndsWith(Path.AltDirectorySeparatorChar))
basePath += Path.DirectorySeparatorChar;
var baseUri = new Uri(basePath);
2026-09-24 19:23:54 +02:00
var targetUri = new Uri(targetPath);
var relativeUri = baseUri.MakeRelativeUri(targetUri);
return Uri.UnescapeDataString(relativeUri.ToString());
}
private void GetImageDimensions(GhostImage image)
{
if (image.FilePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
return;
try
{
using var fs = File.OpenRead(image.FilePath);
var (width, height) = ImageDimensionReader.Read(fs);
image.Width = width;
image.Height = height;
}
catch (Exception ex)
{
2026-09-28 19:09:09 +02:00
_logger.LogWarning(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath);
2026-09-24 19:23:54 +02:00
}
}
}