using System.Text.RegularExpressions; using GhostImageManager.Data; using GhostImageManager.Models; using HtmlAgilityPack; namespace GhostImageManager.Services; public class ImageAnalyzerService { private readonly GhostApiService _ghostApiService; private readonly ExifService _exifService; private readonly ImageDatabaseService _dbService; private readonly ILogger _logger; public ImageAnalyzerService( GhostApiService ghostApiService, ExifService exifService, ImageDatabaseService dbService, ILogger logger) { _ghostApiService = ghostApiService; _exifService = exifService; _dbService = dbService; _logger = logger; } // 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+\\?/)?)?(?[^""'\s\\()<>?#,]+(?:\\?/[^""'\s\\()<>?#,]+)*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); public async Task> AnalyzeImagesAsync(GhostInstance instance) { var images = new List(); LastWarning = null; _logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath); if (!Directory.Exists(instance.ImageFolderPath)) { _logger.LogError("Image folder not found: {Path}", instance.ImageFolderPath); LastWarning = $"Bildordner nicht gefunden: {instance.ImageFolderPath}"; return images; } // 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); // 2. Bild-Referenzen sammeln. Schlüssel: relativer Pfad unter content/images (Ghost-Bilder) // bzw. die vollständige URL (externe Bilder) var imageReferences = new Dictionary>(StringComparer.OrdinalIgnoreCase); PostReference GetReference(string key, GhostPost post) { if (!imageReferences.TryGetValue(key, out var list)) imageReferences[key] = list = new List(); 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; } foreach (var post in posts) { // Feature Image if (!string.IsNullOrEmpty(post.FeatureImage)) GetReference(GetReferenceKey(post.FeatureImage), post).IsFeatureImage = true; // 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; // Externe Bilder im HTML foreach (var imgUrl in ExtractImageUrls(post.Html)) { if (!ContentImageRegex.IsMatch(imgUrl)) GetReference(imgUrl, post).IsInContent = true; } } // 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); } // 3. Lokale Dateien scannen _logger.LogInformation("Scanne lokalen Image-Ordner: {Folder}", instance.ImageFolderPath); var localFiles = ScanLocalImageFiles(instance.ImageFolderPath); _logger.LogInformation("{Count} lokale Bilddateien gefunden", localFiles.Count); 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); } // 4. GhostImage-Objekte erstellen und mit DB synchronisieren foreach (var file in localFiles) { var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName); var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName); imageReferences.TryGetValue(relativePath, out var refs); var image = new GhostImage { FilePath = file.FullName, FileName = file.Name, RelativePath = relativePath, Url = absoluteUrl, FileSize = file.Length, LastModified = file.LastWriteTime, UsedInPosts = refs ?? new List(), IsOrphaned = refs == null }; // "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); } // Dimensionen ermitteln GetImageDimensions(image); // 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); images.Add(image); } // Auch URLs aus Posts, die lokal nicht existieren, hinzufügen (z.B. externe Bilder) foreach (var key in imageReferences.Keys) { if (localPaths.Contains(key)) continue; var url = IsExternalUrl(key) ? key : $"{instance.Url.TrimEnd('/')}/content/images/{key}"; var localPath = ConvertUrlToLocalPath(instance, url); var externalImage = new GhostImage { 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); } var orphanedCount = images.Count(i => i.IsOrphaned); _logger.LogInformation("Bildanalyse abgeschlossen: {Total} Bilder, {Orphaned} verwaist, {Used} verwendet", images.Count, orphanedCount, images.Count - orphanedCount); return images; } 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; image.Category = managedImage.Category; // CaptureDate aus DB hat Priorität, falls manuell überschrieben if (managedImage.CaptureDate.HasValue) image.CaptureDate = managedImage.CaptureDate.Value; } private List ScanLocalImageFiles(string folderPath) { var files = new List(); var extensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" }; if (!Directory.Exists(folderPath)) return files; foreach (var ext in extensions) { files.AddRange(new DirectoryInfo(folderPath) .EnumerateFiles($"*{ext}", SearchOption.AllDirectories)); } var excludedDirs = Path.Combine(folderPath, "size"); return files.Where(f => f.DirectoryName.StartsWith(excludedDirs) == false).OrderBy(f => f.FullName).ToList(); } private List ExtractImageUrls(string html) { var urls = new List(); 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) { _logger.LogError(ex, "Fehler beim Parsen von HTML nach Bild-URLs"); } return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } private static List 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; } private string ConvertLocalPathToUrl(GhostInstance instance, string localPath) { var relativePath = GetRelativePath(instance.ImageFolderPath, localPath); 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..]; return Path.Combine(instance.ImageFolderPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); } return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url)); } private string GetRelativePath(string basePath, string targetPath) { if (!basePath.EndsWith(Path.DirectorySeparatorChar) && !basePath.EndsWith(Path.AltDirectorySeparatorChar)) basePath += Path.DirectorySeparatorChar; var baseUri = new Uri(basePath); 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) { _logger.LogWarning(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath); } } }