-
-
Vollständiger Pfad zum Ghost content/images Ordner
+
+
Vollständiger Pfad zum Ghost content/images Ordner (Windows oder Linux)
diff --git a/GhostImageManager/Data/AppDbContext.cs b/GhostImageManager/Data/AppDbContext.cs
new file mode 100644
index 0000000..07b8a97
--- /dev/null
+++ b/GhostImageManager/Data/AppDbContext.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace GhostImageManager.Data;
+
+public class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions
options) : base(options)
+ {
+ }
+
+ public DbSet ManagedImages => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.GhostImageUrl);
+ entity.HasIndex(e => e.GhostInstanceId);
+ entity.HasIndex(e => e.FileName);
+ entity.HasIndex(e => e.AuthorId);
+ entity.HasIndex(e => e.Theme);
+ entity.HasIndex(e => e.CaptureDate);
+
+ entity.Property(e => e.GhostImageUrl).HasMaxLength(2000);
+ entity.Property(e => e.RelativePath).HasMaxLength(1000);
+ entity.Property(e => e.FileName).HasMaxLength(500);
+ entity.Property(e => e.GhostInstanceId).HasMaxLength(100);
+ entity.Property(e => e.AuthorId).HasMaxLength(100);
+ entity.Property(e => e.AuthorName).HasMaxLength(200);
+ entity.Property(e => e.CameraMake).HasMaxLength(200);
+ entity.Property(e => e.CameraModel).HasMaxLength(200);
+ entity.Property(e => e.Lens).HasMaxLength(200);
+ entity.Property(e => e.Aperture).HasMaxLength(50);
+ entity.Property(e => e.ShutterSpeed).HasMaxLength(50);
+ entity.Property(e => e.Iso).HasMaxLength(50);
+ entity.Property(e => e.FocalLength).HasMaxLength(50);
+ entity.Property(e => e.Theme).HasMaxLength(500);
+ });
+ }
+}
diff --git a/GhostImageManager/Data/ManagedImage.cs b/GhostImageManager/Data/ManagedImage.cs
new file mode 100644
index 0000000..057d2f3
--- /dev/null
+++ b/GhostImageManager/Data/ManagedImage.cs
@@ -0,0 +1,44 @@
+namespace GhostImageManager.Data;
+
+public class ManagedImage
+{
+ public int Id { get; set; }
+
+ // Verknüpfung zum Ghost-Bild
+ public string GhostImageUrl { get; set; } = "";
+ public string RelativePath { get; set; } = "";
+ public string FileName { get; set; } = "";
+
+ // Instanz-Zuordnung
+ public string GhostInstanceId { get; set; } = "";
+
+ // Autor (Ghost-Benutzer)
+ public string? AuthorId { get; set; }
+ public string? AuthorName { get; set; }
+
+ // EXIF-Daten
+ public string? CameraMake { get; set; }
+ public string? CameraModel { get; set; }
+ public string? Lens { get; set; }
+ public string? Aperture { get; set; }
+ public string? ShutterSpeed { get; set; }
+ public string? Iso { get; set; }
+ public string? FocalLength { get; set; }
+
+ // Thema / Topic
+ public string? Theme { get; set; }
+
+ // Aufnahmedatum
+ public DateTime? CaptureDate { get; set; }
+
+ // Datei-Metadaten
+ public long FileSize { get; set; }
+ public int Width { get; set; }
+ public int Height { get; set; }
+ public DateTime FileModifiedDate { get; set; }
+
+ // Verwaltung
+ public DateTime FirstSeen { get; set; }
+ public DateTime LastUpdated { get; set; }
+ public bool IsOrphaned { get; set; }
+}
diff --git a/GhostImageManager/GhostImageManager.csproj b/GhostImageManager/GhostImageManager.csproj
index 5ce8a54..c5e209d 100644
--- a/GhostImageManager/GhostImageManager.csproj
+++ b/GhostImageManager/GhostImageManager.csproj
@@ -9,6 +9,10 @@
+
+
+
+
diff --git a/GhostImageManager/GhostImageManager.slnx b/GhostImageManager/GhostImageManager.slnx
new file mode 100644
index 0000000..f56979d
--- /dev/null
+++ b/GhostImageManager/GhostImageManager.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/GhostImageManager/Models/GhostImage.cs b/GhostImageManager/Models/GhostImage.cs
index 1ffe21e..e371742 100644
--- a/GhostImageManager/Models/GhostImage.cs
+++ b/GhostImageManager/Models/GhostImage.cs
@@ -2,6 +2,7 @@ namespace GhostImageManager.Models;
public class GhostImage
{
+ public int? ManagedImageId { get; set; }
public string Url { get; set; } = "";
public string FileName { get; set; } = "";
public string RelativePath { get; set; } = "";
@@ -13,6 +14,21 @@ public class GhostImage
public bool IsOrphaned { get; set; } = true;
public List UsedInPosts { get; set; } = new();
+ // Aus der Datenbank
+ public string? AuthorId { get; set; }
+ public string? AuthorName { get; set; }
+ public string? Theme { get; set; }
+ public DateTime? CaptureDate { get; set; }
+
+ // EXIF
+ public string? CameraMake { get; set; }
+ public string? CameraModel { get; set; }
+ public string? Lens { get; set; }
+ public string? Aperture { get; set; }
+ public string? ShutterSpeed { get; set; }
+ public string? Iso { get; set; }
+ public string? FocalLength { get; set; }
+
public string FormattedFileSize => FileSize switch
{
>= 1024 * 1024 => $"{FileSize / (1024.0 * 1024.0):F2} MB",
@@ -21,6 +37,12 @@ public class GhostImage
};
public string Dimensions => Width > 0 && Height > 0 ? $"{Width}x{Height}" : "n/a";
+
+ public string CameraDisplay => string.IsNullOrEmpty(CameraModel)
+ ? "n/a"
+ : $"{CameraMake} {CameraModel}".Trim();
+
+ public string ExifSummary => $"{Aperture ?? "-"} | {ShutterSpeed ?? "-"} | ISO {Iso ?? "-"} | {FocalLength ?? "-"}";
}
public class PostReference
diff --git a/GhostImageManager/Models/GhostUser.cs b/GhostImageManager/Models/GhostUser.cs
new file mode 100644
index 0000000..c9be8fd
--- /dev/null
+++ b/GhostImageManager/Models/GhostUser.cs
@@ -0,0 +1,10 @@
+namespace GhostImageManager.Models;
+
+public class GhostUser
+{
+ public string Id { get; set; } = "";
+ public string Name { get; set; } = "";
+ public string Slug { get; set; } = "";
+ public string? Email { get; set; }
+ public string? ProfileImage { get; set; }
+}
diff --git a/GhostImageManager/Program.cs b/GhostImageManager/Program.cs
index 78e1550..11abea7 100644
--- a/GhostImageManager/Program.cs
+++ b/GhostImageManager/Program.cs
@@ -1,14 +1,42 @@
using GhostImageManager.Components;
+using GhostImageManager.Data;
using GhostImageManager.Models;
using GhostImageManager.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.EntityFrameworkCore;
+using Serilog;
var builder = WebApplication.CreateBuilder(args);
+// Logging konfigurieren
+var logsDir = Path.Combine(builder.Environment.ContentRootPath, "logs");
+if (!Directory.Exists(logsDir))
+ Directory.CreateDirectory(logsDir);
+
+Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
+ .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
+ .Enrich.FromLogContext()
+ .WriteTo.Console()
+ .WriteTo.File(
+ Path.Combine(logsDir, "ghost-image-manager-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 30,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+
+builder.Host.UseSerilog();
+
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
+// Datenbank
+var dbPath = Path.Combine(builder.Environment.ContentRootPath, "ghost-image-manager.db");
+builder.Services.AddDbContext(options =>
+ options.UseSqlite($"Data Source={dbPath}"));
+
builder.Services.Configure(builder.Configuration.GetSection("AppSettings"));
builder.Services.AddSingleton();
builder.Services.AddScoped();
@@ -16,7 +44,10 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddSingleton();
builder.Services.AddHttpClient();
// Authentication
@@ -41,6 +72,13 @@ builder.WebHost.ConfigureKestrel(options =>
var app = builder.Build();
+// Datenbank sicherstellen
+using (var scope = app.Services.CreateScope())
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureCreated();
+}
+
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
@@ -73,7 +111,7 @@ app.MapRazorComponents()
.AddInteractiveServerRenderMode();
// Authentication API endpoints
-app.MapPost("/auth/login", async (HttpContext context, AuthService authService) =>
+app.MapPost("/auth/login", async (HttpContext context, AuthService authService, ILogger logger) =>
{
var form = await context.Request.ReadFormAsync();
var password = form["password"].ToString();
@@ -84,19 +122,25 @@ app.MapPost("/auth/login", async (HttpContext context, AuthService authService)
try
{
+ logger.LogInformation("Benutzer meldet sich an");
await authService.SignInAsync(context, password);
+ logger.LogInformation("Anmeldung erfolgreich");
return Results.Redirect(returnUrl);
}
catch (UnauthorizedAccessException)
{
+ logger.LogWarning("Fehlgeschlagene Anmeldung");
return Results.Redirect($"/login?errorMessage={Uri.EscapeDataString("Ungültiges Passwort.")}&returnUrl={Uri.EscapeDataString(returnUrl)}");
}
});
-app.MapPost("/auth/logout", async (HttpContext context, AuthService authService) =>
+app.MapPost("/auth/logout", async (HttpContext context, AuthService authService, ILogger logger) =>
{
+ logger.LogInformation("Benutzer meldet sich ab");
await authService.SignOutAsync(context);
return Results.Redirect("/login");
});
app.Run();
+
+Log.CloseAndFlush();
diff --git a/GhostImageManager/Services/AuthService.cs b/GhostImageManager/Services/AuthService.cs
index 37af5d8..7a73db1 100644
--- a/GhostImageManager/Services/AuthService.cs
+++ b/GhostImageManager/Services/AuthService.cs
@@ -8,16 +8,25 @@ namespace GhostImageManager.Services;
public class AuthService
{
private readonly AppSettings _appSettings;
+ private readonly ILogger _logger;
- public AuthService(IConfiguration configuration)
+ public AuthService(IConfiguration configuration, ILogger logger)
{
_appSettings = configuration.GetSection("AppSettings").Get() ?? new AppSettings();
+ _logger = logger;
}
public bool ValidatePassword(string password)
{
- return !string.IsNullOrEmpty(password) &&
- password == _appSettings.Password;
+ var isValid = !string.IsNullOrEmpty(password) &&
+ password == _appSettings.Password;
+
+ if (!isValid)
+ {
+ _logger.LogWarning("Passwort-Validierung fehlgeschlagen");
+ }
+
+ return isValid;
}
public async Task SignInAsync(HttpContext context, string password)
diff --git a/GhostImageManager/Services/ExifService.cs b/GhostImageManager/Services/ExifService.cs
new file mode 100644
index 0000000..5ee412f
--- /dev/null
+++ b/GhostImageManager/Services/ExifService.cs
@@ -0,0 +1,92 @@
+using MetadataExtractor;
+using MetadataExtractor.Formats.Exif;
+
+namespace GhostImageManager.Services;
+
+public class ExifData
+{
+ public string? CameraMake { get; set; }
+ public string? CameraModel { get; set; }
+ public string? Lens { get; set; }
+ public string? Aperture { get; set; }
+ public string? ShutterSpeed { get; set; }
+ public string? Iso { get; set; }
+ public string? FocalLength { get; set; }
+ public DateTime? CaptureDate { get; set; }
+}
+
+public class ExifService
+{
+ private readonly ILogger _logger;
+
+ public ExifService(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public ExifData ExtractExifData(string filePath)
+ {
+ var result = new ExifData();
+
+ try
+ {
+ if (!File.Exists(filePath))
+ {
+ _logger.LogWarning("EXIF-Extraktion: Datei nicht gefunden {FilePath}", filePath);
+ return result;
+ }
+
+ var directories = ImageMetadataReader.ReadMetadata(filePath);
+ var ifd0Directory = directories.OfType().FirstOrDefault();
+ var subIfdDirectory = directories.OfType().FirstOrDefault();
+ var exifDirectory = directories.OfType().FirstOrDefault();
+
+ // Kamera
+ if (ifd0Directory != null)
+ {
+ result.CameraMake = ifd0Directory.GetDescription(ExifDirectoryBase.TagMake);
+ result.CameraModel = ifd0Directory.GetDescription(ExifDirectoryBase.TagModel);
+ }
+
+ // Aufnahmedatum
+ if (exifDirectory != null && exifDirectory.TryGetDateTime(ExifDirectoryBase.TagDateTimeOriginal, out var captureDate))
+ {
+ result.CaptureDate = captureDate;
+ }
+ else if (exifDirectory != null && exifDirectory.TryGetDateTime(ExifDirectoryBase.TagDateTime, out var dateTime))
+ {
+ result.CaptureDate = dateTime;
+ }
+
+ if (subIfdDirectory != null)
+ {
+ // Blende
+ result.Aperture = subIfdDirectory.GetDescription(ExifDirectoryBase.TagAperture);
+ if (string.IsNullOrEmpty(result.Aperture))
+ result.Aperture = subIfdDirectory.GetDescription(ExifDirectoryBase.TagFNumber);
+
+ // Verschlusszeit
+ result.ShutterSpeed = subIfdDirectory.GetDescription(ExifDirectoryBase.TagExposureTime);
+
+ // ISO
+ result.Iso = subIfdDirectory.GetDescription(ExifDirectoryBase.TagIsoEquivalent);
+
+ // Brennweite
+ result.FocalLength = subIfdDirectory.GetDescription(ExifDirectoryBase.TagFocalLength);
+
+ // Objektiv
+ result.Lens = subIfdDirectory.GetDescription(ExifDirectoryBase.TagLensModel);
+ if (string.IsNullOrEmpty(result.Lens))
+ result.Lens = subIfdDirectory.GetDescription(ExifDirectoryBase.TagLensSpecification);
+ }
+
+ _logger.LogDebug("EXIF-Daten für {File} extrahiert", Path.GetFileName(filePath));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Fehler beim Extrahieren der EXIF-Daten für {FilePath}", filePath);
+ }
+
+ return result;
+ }
+}
diff --git a/GhostImageManager/Services/GhostApiService.cs b/GhostImageManager/Services/GhostApiService.cs
index 4089fa4..7959fb2 100644
--- a/GhostImageManager/Services/GhostApiService.cs
+++ b/GhostImageManager/Services/GhostApiService.cs
@@ -1,25 +1,34 @@
using System.Text;
using System.Text.Json;
-using System.Text.Json.Serialization;
using GhostImageManager.Models;
-using HtmlAgilityPack;
namespace GhostImageManager.Services;
public class GhostApiService
{
private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
- public GhostApiService(HttpClient httpClient)
+ public GhostApiService(HttpClient httpClient, ILogger logger)
{
_httpClient = httpClient;
+ _logger = logger;
}
public async Task> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 100)
{
- var request = CreateRequest(HttpMethod.Get, instance, $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&fields=id,title,slug,feature_image,html,created_at,updated_at,status");
+ var endpoint = $"posts/?limit={limit}&filter={Uri.EscapeDataString(filter)}&formats=html,plaintext&fields=id,title,slug,feature_image,html,created_at,updated_at,status";
+ _logger.LogInformation("Hole Posts von {Url} (Filter: {Filter}, Limit: {Limit})", instance.Url, filter, limit);
+
+ var request = CreateRequest(HttpMethod.Get, instance, endpoint);
var response = await _httpClient.SendAsync(request);
- response.EnsureSuccessStatusCode();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError("Ghost API Fehler beim Laden der Posts: {StatusCode} - {Error}", response.StatusCode, errorContent);
+ response.EnsureSuccessStatusCode();
+ }
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
@@ -43,14 +52,23 @@ public class GhostApiService
}
}
+ _logger.LogInformation("{Count} Posts von Ghost geladen", posts.Count);
return posts;
}
public async Task GetPostAsync(GhostInstance instance, string postId)
{
+ _logger.LogInformation("Lade Post {PostId} von {Url}", postId, instance.Url);
+
var request = CreateRequest(HttpMethod.Get, instance, $"posts/{postId}/?fields=id,title,slug,feature_image,html,created_at,updated_at,status");
var response = await _httpClient.SendAsync(request);
- if (!response.IsSuccessStatusCode) return null;
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError("Ghost API Fehler beim Laden von Post {PostId}: {StatusCode} - {Error}", postId, response.StatusCode, errorContent);
+ return null;
+ }
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
@@ -76,6 +94,8 @@ public class GhostApiService
public async Task UpdatePostAsync(GhostInstance instance, GhostPost post)
{
+ _logger.LogInformation("Aktualisiere Post {PostId} ({Title}) in Ghost", post.Id, post.Title);
+
var request = CreateRequest(HttpMethod.Put, instance, $"posts/{post.Id}/?source=html");
var body = new
{
@@ -95,11 +115,59 @@ public class GhostApiService
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request);
- return response.IsSuccessStatusCode;
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError("Ghost API Fehler beim Aktualisieren von Post {PostId}: {StatusCode} - {Error}", post.Id, response.StatusCode, errorContent);
+ return false;
+ }
+
+ _logger.LogInformation("Post {PostId} erfolgreich aktualisiert", post.Id);
+ return true;
+ }
+
+ public async Task> GetUsersAsync(GhostInstance instance)
+ {
+ _logger.LogInformation("Hole Benutzer von {Url}", instance.Url);
+
+ var request = CreateRequest(HttpMethod.Get, instance, "users/?limit=100&fields=id,name,slug,email,profile_image");
+ var response = await _httpClient.SendAsync(request);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError("Ghost API Fehler beim Laden der Benutzer: {StatusCode} - {Error}", response.StatusCode, errorContent);
+ return new List();
+ }
+
+ var json = await response.Content.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(json);
+ var users = new List();
+
+ if (doc.RootElement.TryGetProperty("users", out var usersElement))
+ {
+ foreach (var user in usersElement.EnumerateArray())
+ {
+ users.Add(new GhostUser
+ {
+ Id = GetString(user, "id"),
+ Name = GetString(user, "name"),
+ Slug = GetString(user, "slug"),
+ Email = GetString(user, "email"),
+ ProfileImage = GetString(user, "profile_image")
+ });
+ }
+ }
+
+ _logger.LogInformation("{Count} Benutzer von Ghost geladen", users.Count);
+ return users;
}
public async Task UploadImageAsync(GhostInstance instance, Stream fileStream, string fileName, string? reference = null)
{
+ _logger.LogInformation("Lade Bild {FileName} zu Ghost hoch", fileName);
+
var token = JwtTokenService.GenerateToken(instance.AdminApiKey);
var request = new HttpRequestMessage(HttpMethod.Post, $"{instance.Url.TrimEnd('/')}/ghost/api/admin/images/upload/");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Ghost", token);
@@ -125,15 +193,24 @@ public class GhostApiService
request.Content = content;
var response = await _httpClient.SendAsync(request);
- if (!response.IsSuccessStatusCode) return null;
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError("Ghost API Fehler beim Hochladen von {FileName}: {StatusCode} - {Error}", fileName, response.StatusCode, errorContent);
+ return null;
+ }
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
{
- return GetString(images[0], "url");
+ var url = GetString(images[0], "url");
+ _logger.LogInformation("Bild {FileName} hochgeladen: {Url}", fileName, url);
+ return url;
}
+ _logger.LogWarning("Keine URL im Upload-Response für {FileName} gefunden", fileName);
return null;
}
@@ -161,9 +238,3 @@ public class GhostApiService
return null;
}
}
-
-public class GhostImageReference
-{
- [JsonPropertyName("url")] public string Url { get; set; } = "";
- [JsonPropertyName("ref")] public string? Ref { get; set; }
-}
diff --git a/GhostImageManager/Services/ImageAnalyzerService.cs b/GhostImageManager/Services/ImageAnalyzerService.cs
index df73147..485fdc3 100644
--- a/GhostImageManager/Services/ImageAnalyzerService.cs
+++ b/GhostImageManager/Services/ImageAnalyzerService.cs
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
+using GhostImageManager.Data;
using GhostImageManager.Models;
using HtmlAgilityPack;
@@ -7,25 +8,37 @@ 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, ILogger logger)
+ public ImageAnalyzerService(
+ GhostApiService ghostApiService,
+ ExifService exifService,
+ ImageDatabaseService dbService,
+ ILogger logger)
{
_ghostApiService = ghostApiService;
+ _exifService = exifService;
+ _dbService = dbService;
_logger = logger;
}
public async Task> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
{
var images = new List();
+ _logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath);
+
if (!Directory.Exists(instance.ImageFolderPath))
{
- _logger.LogWarning("Image folder not found: {Path}", instance.ImageFolderPath);
+ _logger.LogError("Image folder not found: {Path}", instance.ImageFolderPath);
return images;
}
// 1. Alle Posts abrufen
+ _logger.LogInformation("Rufe Posts mit Filter '{Filter}' ab", searchTerm);
var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm);
+ _logger.LogInformation("{Count} Posts geladen, extrahiere Bild-Referenzen", posts.Count);
// 2. Bild-Referenzen aus Posts sammeln
var imageReferences = new Dictionary>(StringComparer.OrdinalIgnoreCase);
@@ -79,13 +92,15 @@ public class ImageAnalyzerService
}
// 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);
- // 4. GhostImage-Objekte erstellen
+ // 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).Replace("\\", "/");
+ var relativePath = GetRelativePath(instance.ImageFolderPath, file.FullName);
var image = new GhostImage
{
@@ -102,6 +117,18 @@ public class ImageAnalyzerService
// 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);
}
@@ -111,21 +138,53 @@ public class ImageAnalyzerService
var localPath = ConvertUrlToLocalPath(instance, url);
if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)))
{
- images.Add(new GhostImage
+ var externalImage = new GhostImage
{
Url = url,
FileName = Path.GetFileName(url),
- RelativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/"),
+ RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
FilePath = localPath,
UsedInPosts = imageReferences[url],
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;
+
+ // 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();
@@ -134,13 +193,16 @@ public class ImageAnalyzerService
if (!Directory.Exists(folderPath))
return files;
+
+
foreach (var ext in extensions)
{
files.AddRange(new DirectoryInfo(folderPath)
.EnumerateFiles($"*{ext}", SearchOption.AllDirectories));
}
- return files.OrderBy(f => f.FullName).ToList();
+ 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)
@@ -164,7 +226,7 @@ public class ImageAnalyzerService
}
catch (Exception ex)
{
- _logger.LogError(ex, "Fehler beim Parsen von HTML");
+ _logger.LogError(ex, "Fehler beim Parsen von HTML nach Bild-URLs");
}
return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
@@ -172,7 +234,7 @@ public class ImageAnalyzerService
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath)
{
- var relativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/");
+ var relativePath = GetRelativePath(instance.ImageFolderPath, localPath);
return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}";
}
@@ -182,14 +244,17 @@ public class ImageAnalyzerService
if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase))
{
var relativePath = url[contentImagesUrl.Length..];
- return Path.Combine(instance.ImageFolderPath, relativePath.Replace("/", "\\"));
+ return Path.Combine(instance.ImageFolderPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
}
return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url));
}
private string GetRelativePath(string basePath, string targetPath)
{
- var baseUri = new Uri(basePath.EndsWith("\\") || basePath.EndsWith("/") ? basePath : basePath + "\\");
+ 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());
@@ -209,7 +274,7 @@ public class ImageAnalyzerService
}
catch (Exception ex)
{
- _logger.LogDebug(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath);
+ _logger.LogWarning(ex, "Konnte Bilddimensionen nicht ermitteln für {File}", image.FilePath);
}
}
}
diff --git a/GhostImageManager/Services/ImageDatabaseService.cs b/GhostImageManager/Services/ImageDatabaseService.cs
new file mode 100644
index 0000000..1a7e598
--- /dev/null
+++ b/GhostImageManager/Services/ImageDatabaseService.cs
@@ -0,0 +1,166 @@
+using GhostImageManager.Data;
+using GhostImageManager.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace GhostImageManager.Services;
+
+public class ImageDatabaseService
+{
+ private readonly AppDbContext _dbContext;
+ private readonly ILogger _logger;
+
+ public ImageDatabaseService(AppDbContext dbContext, ILogger logger)
+ {
+ _dbContext = dbContext;
+ _logger = logger;
+ }
+
+ public async Task GetByUrlAsync(string ghostImageUrl, string instanceId)
+ {
+ return await _dbContext.ManagedImages
+ .FirstOrDefaultAsync(m => m.GhostImageUrl == ghostImageUrl && m.GhostInstanceId == instanceId);
+ }
+
+ public async Task> GetByInstanceAsync(string instanceId)
+ {
+ return await _dbContext.ManagedImages
+ .Where(m => m.GhostInstanceId == instanceId)
+ .OrderBy(m => m.FileName)
+ .ToListAsync();
+ }
+
+ public async Task SyncImageAsync(GhostImage ghostImage, string instanceId, ExifData? exifData = null)
+ {
+ var existing = await GetByUrlAsync(ghostImage.Url, instanceId);
+ var now = DateTime.UtcNow;
+
+ if (existing == null)
+ {
+ existing = new ManagedImage
+ {
+ GhostImageUrl = ghostImage.Url,
+ RelativePath = ghostImage.RelativePath,
+ FileName = ghostImage.FileName,
+ GhostInstanceId = instanceId,
+ FirstSeen = now,
+ IsOrphaned = ghostImage.IsOrphaned
+ };
+ _dbContext.ManagedImages.Add(existing);
+ _logger.LogInformation("Neues ManagedImage angelegt: {FileName}", ghostImage.FileName);
+ }
+
+ // Felder aktualisieren, die sich ändern können
+ existing.RelativePath = ghostImage.RelativePath;
+ existing.FileName = ghostImage.FileName;
+ existing.FileSize = ghostImage.FileSize;
+ existing.Width = ghostImage.Width;
+ existing.Height = ghostImage.Height;
+ existing.FileModifiedDate = ghostImage.LastModified;
+ existing.IsOrphaned = ghostImage.IsOrphaned;
+ existing.LastUpdated = now;
+
+ // EXIF-Daten nur überschreiben, wenn sie vorhanden sind
+ if (exifData != null)
+ {
+ if (!string.IsNullOrEmpty(exifData.CameraMake))
+ existing.CameraMake = exifData.CameraMake;
+ if (!string.IsNullOrEmpty(exifData.CameraModel))
+ existing.CameraModel = exifData.CameraModel;
+ if (!string.IsNullOrEmpty(exifData.Lens))
+ existing.Lens = exifData.Lens;
+ if (!string.IsNullOrEmpty(exifData.Aperture))
+ existing.Aperture = exifData.Aperture;
+ if (!string.IsNullOrEmpty(exifData.ShutterSpeed))
+ existing.ShutterSpeed = exifData.ShutterSpeed;
+ if (!string.IsNullOrEmpty(exifData.Iso))
+ existing.Iso = exifData.Iso;
+ if (!string.IsNullOrEmpty(exifData.FocalLength))
+ existing.FocalLength = exifData.FocalLength;
+ if (exifData.CaptureDate.HasValue)
+ existing.CaptureDate = exifData.CaptureDate.Value;
+ }
+
+ await _dbContext.SaveChangesAsync();
+ return existing;
+ }
+
+ public async Task UpdateAuthorAsync(int id, string? authorId, string? authorName)
+ {
+ var image = await _dbContext.ManagedImages.FindAsync(id);
+ if (image == null) return;
+
+ image.AuthorId = authorId;
+ image.AuthorName = authorName;
+ image.LastUpdated = DateTime.UtcNow;
+ await _dbContext.SaveChangesAsync();
+ _logger.LogInformation("Autor für {FileName} aktualisiert: {AuthorName}", image.FileName, authorName);
+ }
+
+ public async Task UpdateThemeAsync(int id, string? theme)
+ {
+ var image = await _dbContext.ManagedImages.FindAsync(id);
+ if (image == null) return;
+
+ image.Theme = theme;
+ image.LastUpdated = DateTime.UtcNow;
+ await _dbContext.SaveChangesAsync();
+ _logger.LogInformation("Thema für {FileName} aktualisiert: {Theme}", image.FileName, theme);
+ }
+
+ public async Task UpdateCaptureDateAsync(int id, DateTime? captureDate)
+ {
+ var image = await _dbContext.ManagedImages.FindAsync(id);
+ if (image == null) return;
+
+ image.CaptureDate = captureDate;
+ image.LastUpdated = DateTime.UtcNow;
+ await _dbContext.SaveChangesAsync();
+ _logger.LogInformation("Aufnahmedatum für {FileName} aktualisiert: {CaptureDate}", image.FileName, captureDate);
+ }
+
+ public async Task DeleteAsync(int id)
+ {
+ var image = await _dbContext.ManagedImages.FindAsync(id);
+ if (image == null) return;
+
+ _dbContext.ManagedImages.Remove(image);
+ await _dbContext.SaveChangesAsync();
+ _logger.LogInformation("ManagedImage gelöscht: {FileName}", image.FileName);
+ }
+
+ public async Task> SearchAsync(string instanceId, string? searchTerm = null, string? theme = null, string? authorId = null)
+ {
+ var query = _dbContext.ManagedImages.Where(m => m.GhostInstanceId == instanceId);
+
+ if (!string.IsNullOrWhiteSpace(searchTerm))
+ {
+ var term = searchTerm.ToLowerInvariant();
+ query = query.Where(m =>
+ (m.FileName != null && m.FileName.ToLower().Contains(term)) ||
+ (m.CameraModel != null && m.CameraModel.ToLower().Contains(term)) ||
+ (m.Theme != null && m.Theme.ToLower().Contains(term)));
+ }
+
+ if (!string.IsNullOrWhiteSpace(theme))
+ {
+ query = query.Where(m => m.Theme == theme);
+ }
+
+ if (!string.IsNullOrWhiteSpace(authorId))
+ {
+ query = query.Where(m => m.AuthorId == authorId);
+ }
+
+ return await query.OrderBy(m => m.FileName).ToListAsync();
+ }
+
+ public async Task> GetThemesAsync(string instanceId)
+ {
+ return await _dbContext.ManagedImages
+ .Where(m => m.GhostInstanceId == instanceId && m.Theme != null && m.Theme != "")
+ .Select(m => m.Theme!)
+ .Distinct()
+ .OrderBy(t => t)
+ .ToListAsync();
+ }
+}
diff --git a/GhostImageManager/Services/ImageDeletionService.cs b/GhostImageManager/Services/ImageDeletionService.cs
index e705869..9125d78 100644
--- a/GhostImageManager/Services/ImageDeletionService.cs
+++ b/GhostImageManager/Services/ImageDeletionService.cs
@@ -13,6 +13,7 @@ public class ImageDeletionService
public Task DeleteImageAsync(GhostImage image)
{
+ _logger.LogInformation("Lösche Bild: {FileName} ({FilePath})", image.FileName, image.FilePath);
return Task.FromResult(DeleteFile(image.FilePath));
}
diff --git a/GhostImageManager/Services/ImageExportService.cs b/GhostImageManager/Services/ImageExportService.cs
index 02f60f2..fb5389e 100644
--- a/GhostImageManager/Services/ImageExportService.cs
+++ b/GhostImageManager/Services/ImageExportService.cs
@@ -6,8 +6,16 @@ namespace GhostImageManager.Services;
public class ImageExportService
{
+ private readonly ILogger _logger;
+
+ public ImageExportService(ILogger logger)
+ {
+ _logger = logger;
+ }
+
public byte[] ExportToCsv(List images)
{
+ _logger.LogInformation("Exportiere {Count} Bilder als CSV", images.Count);
var sb = new StringBuilder();
sb.AppendLine("Dateiname;Relativer Pfad;URL;Größe Bytes;Größe;Dimensionen;Letzte Änderung;Verwaist;Verwendet in Posts");
@@ -22,6 +30,7 @@ public class ImageExportService
public byte[] ExportToTxt(List images)
{
+ _logger.LogInformation("Exportiere {Count} Bilder als TXT", images.Count);
var sb = new StringBuilder();
sb.AppendLine("Ghost Image Export");
sb.AppendLine($"Erstellt am: {DateTime.Now:yyyy-MM-dd HH:mm}");
@@ -51,6 +60,7 @@ public class ImageExportService
public async Task ExportToZipAsync(List images)
{
+ _logger.LogInformation("Exportiere {Count} Bilder als ZIP", images.Count);
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
@@ -59,7 +69,7 @@ public class ImageExportService
if (!File.Exists(image.FilePath))
continue;
- var entry = archive.CreateEntry(image.RelativePath.Replace("/", "\\"));
+ var entry = archive.CreateEntry(image.RelativePath.Replace("\\", "/"));
await using (var entryStream = entry.Open())
{
await using var fileStream = File.OpenRead(image.FilePath);
diff --git a/GhostImageManager/Services/ImageReplacementService.cs b/GhostImageManager/Services/ImageReplacementService.cs
index 78c2ec2..10b3f02 100644
--- a/GhostImageManager/Services/ImageReplacementService.cs
+++ b/GhostImageManager/Services/ImageReplacementService.cs
@@ -13,6 +13,8 @@ public class ImageReplacementService
public async Task ReplaceImageAsync(GhostImage oldImage, Stream newImageStream, string newFileName)
{
+ _logger.LogInformation("Ersetze Bild {OldFile} durch {NewFile}", oldImage.FileName, newFileName);
+
try
{
if (!File.Exists(oldImage.FilePath))
@@ -24,6 +26,7 @@ public class ImageReplacementService
// Backup der alten Datei erstellen
var backupPath = oldImage.FilePath + ".backup";
File.Copy(oldImage.FilePath, backupPath, overwrite: true);
+ _logger.LogDebug("Backup erstellt: {BackupPath}", backupPath);
// Neue Datei schreiben
using (var fileStream = File.Create(oldImage.FilePath))
diff --git a/GhostImageManager/ghost-instances.json b/GhostImageManager/ghost-instances.json
index 6e25f47..4425e57 100644
--- a/GhostImageManager/ghost-instances.json
+++ b/GhostImageManager/ghost-instances.json
@@ -1,12 +1,12 @@
{
"instances": [
{
- "id": "prod",
- "name": "Produktion",
- "url": "https://www.brigitteundrolandunterwegs.com",
- "adminApiKey": "HIER_ADMIN_API_KEY_EINGEBEN",
- "contentApiKey": "",
- "imageFolderPath": "C:\\Ghost\\content\\images"
+ "id": "41512d16",
+ "name": "RBUBB",
+ "url": "https://rbubb.rolandbrunner.me",
+ "adminApiKey": "6aa18e1e05dde700011bc18a:f32a3467ec3351ebe693be411f1cfc4be063ed85b467f157e67bdc750c1aa251",
+ "contentApiKey": "467ee6c8bf369a09cc57446256",
+ "imageFolderPath": "E:\\Wp2Ghost\\images"
}
]
-}
+}
\ No newline at end of file
diff --git a/GhostImageManager/wwwroot/favicon.png b/GhostImageManager/wwwroot/favicon.png
index 62bf214..86a4a17 100644
Binary files a/GhostImageManager/wwwroot/favicon.png and b/GhostImageManager/wwwroot/favicon.png differ