- ghost admin

This commit is contained in:
Roland 2026-09-28 19:09:09 +02:00
parent 00d2681a70
commit a730639c2a
22 changed files with 1036 additions and 56 deletions

56
.gitignore vendored
View file

@ -6,3 +6,59 @@
/GhostImageManager/bin/* /GhostImageManager/bin/*
/GhostImageManager/obj/* /GhostImageManager/obj/*
/GhostImageManager/Properties/* /GhostImageManager/Properties/*
/GhostImageManager/logs/ghost-image-manager-20260927.log
/GhostImageManager/logs/ghost-image-manager-20260928.log
/GhostImageManager/ghost-image-manager.db*
/images/2026/09/20222424_1_o.jpg
/images/2026/09/20222424_1.jpg
/images/2026/09/20222424_10_o.jpg
/images/2026/09/20222424_10.jpg
/images/2026/09/20222424_11_o.jpg
/images/2026/09/20222424_11.jpg
/images/2026/09/20222424_12_o.jpg
/images/2026/09/20222424_12.jpg
/images/2026/09/20222424_13_o.jpg
/images/2026/09/20222424_13.jpg
/images/2026/09/20222424_14_o.jpg
/images/2026/09/20222424_14.jpg
/images/2026/09/20222424_15_o.jpg
/images/2026/09/20222424_15.jpg
/images/2026/09/20222424_16_o.jpg
/images/2026/09/20222424_16.jpg
/images/2026/09/20222424_17_o.jpg
/images/2026/09/20222424_17.jpg
/images/2026/09/20222424_18_o.jpg
/images/2026/09/20222424_18.jpg
/images/2026/09/20222424_19_o.jpg
/images/2026/09/20222424_19.jpg
/images/2026/09/20222424_2_o.jpg
/images/2026/09/20222424_2.jpg
/images/2026/09/20222424_20_o.jpg
/images/2026/09/20222424_20.jpg
/images/2026/09/20222424_21_o.jpg
/images/2026/09/20222424_21.jpg
/images/2026/09/20222424_22_o.jpg
/images/2026/09/20222424_22.jpg
/images/2026/09/20222424_23_o.jpg
/images/2026/09/20222424_23.jpg
/images/2026/09/20222424_24_o.jpg
/images/2026/09/20222424_24.jpg
/images/2026/09/20222424_25_o.jpg
/images/2026/09/20222424_25.jpg
/images/2026/09/20222424_3_o.jpg
/images/2026/09/20222424_3.jpg
/images/2026/09/20222424_4_o.jpg
/images/2026/09/20222424_4.jpg
/images/2026/09/20222424_5_o.jpg
/images/2026/09/20222424_5.jpg
/images/2026/09/20222424_6_o.jpg
/images/2026/09/20222424_6.jpg
/images/2026/09/20222424_7_o.jpg
/images/2026/09/20222424_7.jpg
/images/2026/09/20222424_8_o.jpg
/images/2026/09/20222424_8.jpg
/images/2026/09/20222424_9_o.jpg
/images/2026/09/20222424_9.jpg
/images/size/w1000/2026/09/20222424_25.jpg
/images/size/w2000/2026/09/20222424_25.jpg
/images/size/w600/2026/09/20222424_25.jpg

View file

@ -20,6 +20,11 @@
<span class="bi bi-images" aria-hidden="true"></span> Bilder <span class="bi bi-images" aria-hidden="true"></span> Bilder
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="logs">
<span class="bi bi-journal-text" aria-hidden="true"></span> Logs
</NavLink>
</div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="settings"> <NavLink class="nav-link" href="settings">
<span class="bi bi-gear-fill" aria-hidden="true"></span> Einstellungen <span class="bi bi-gear-fill" aria-hidden="true"></span> Einstellungen

View file

@ -2,10 +2,12 @@
@attribute [Authorize] @attribute [Authorize]
@rendermode InteractiveServer @rendermode InteractiveServer
@inject GhostConfigService ConfigService @inject GhostConfigService ConfigService
@inject GhostApiService GhostApi
@inject ImageAnalyzerService ImageAnalyzer @inject ImageAnalyzerService ImageAnalyzer
@inject ImageReplacementService ReplacementService @inject ImageReplacementService ReplacementService
@inject ImageDeletionService DeletionService @inject ImageDeletionService DeletionService
@inject ImageExportService ExportService @inject ImageExportService ExportService
@inject ImageDatabaseService DbService
@inject IJSRuntime JsRuntime @inject IJSRuntime JsRuntime
@inject ILogger<Images> Logger @inject ILogger<Images> Logger
@ -22,7 +24,7 @@
else else
{ {
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label">Ghost-Instanz</label> <label class="form-label">Ghost-Instanz</label>
<select class="form-select" @bind="_selectedInstanceId" @bind:after="OnInstanceChanged"> <select class="form-select" @bind="_selectedInstanceId" @bind:after="OnInstanceChanged">
@foreach (var instance in _instances) @foreach (var instance in _instances)
@ -31,14 +33,37 @@ else
} }
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<label class="form-label">Suche nach Post</label> <label class="form-label">Suche nach Post</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" @bind="_searchTerm" @bind:event="oninput" placeholder="Post-Titel..." /> <input type="text" class="form-control" @bind="_searchTerm" @bind:event="oninput" placeholder="Post-Titel..." />
<button class="btn btn-outline-secondary" @onclick="LoadImages" disabled="@_isLoading">Suchen</button> <button class="btn btn-outline-secondary" @onclick="LoadImages" disabled="@_isLoading">Suchen</button>
</div> </div>
</div> </div>
<div class="col-md-6 d-flex align-items-end"> <div class="col-md-2">
<label class="form-label">Autor</label>
<select class="form-select" @bind="_authorFilter" @bind:after="ApplyFilters">
<option value="">Alle Autoren</option>
@foreach (var author in AvailableAuthors)
{
<option value="@author.Key">@author.Value (@GetAuthorCount(author.Key))</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label">Thema</label>
<select class="form-select" @bind="_themeFilter" @bind:after="ApplyFilters">
<option value="">Alle Themen</option>
@foreach (var theme in AvailableThemes)
{
<option value="@theme">@theme (@GetThemeCount(theme))</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>
</button>
<button class="btn btn-primary me-2" @onclick="LoadImages" disabled="@_isLoading"> <button class="btn btn-primary me-2" @onclick="LoadImages" disabled="@_isLoading">
@if (_isLoading) @if (_isLoading)
{ {
@ -95,6 +120,19 @@ else
<button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button> <button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button>
</div> </div>
</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))
{
<span class="ms-2">
(@(!string.IsNullOrWhiteSpace(_authorFilter) ? "Autor" : "")
@(!string.IsNullOrWhiteSpace(_authorFilter) && !string.IsNullOrWhiteSpace(_themeFilter) ? " + " : "")
@(!string.IsNullOrWhiteSpace(_themeFilter) ? "Thema" : ""))
</span>
}
</span>
</div>
<div class="col-auto"> <div class="col-auto">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllOrphaned" @onchange="ToggleSelectAllOrphaned" checked="@_selectAllOrphaned" /> <input class="form-check-input" type="checkbox" id="selectAllOrphaned" @onchange="ToggleSelectAllOrphaned" checked="@_selectAllOrphaned" />
@ -126,7 +164,24 @@ else
<strong>Pfad:</strong> <span class="text-truncate d-block" title="@image.RelativePath">@image.RelativePath</span> <strong>Pfad:</strong> <span class="text-truncate d-block" title="@image.RelativePath">@image.RelativePath</span>
<strong>Größe:</strong> @image.FormattedFileSize<br /> <strong>Größe:</strong> @image.FormattedFileSize<br />
<strong>Dimensionen:</strong> @image.Dimensions<br /> <strong>Dimensionen:</strong> @image.Dimensions<br />
<strong>Datum:</strong> @image.LastModified.ToString("dd.MM.yyyy HH:mm") <strong>Datei-Datum:</strong> @image.LastModified.ToString("dd.MM.yyyy HH:mm")<br />
@if (!string.IsNullOrEmpty(image.AuthorName))
{
<strong>Autor:</strong> @image.AuthorName<br />
}
@if (!string.IsNullOrEmpty(image.Theme))
{
<strong>Thema:</strong> @image.Theme<br />
}
@if (image.CaptureDate.HasValue)
{
<strong>Aufnahmedatum:</strong> @image.CaptureDate.Value.ToString("dd.MM.yyyy HH:mm")<br />
}
@if (!string.IsNullOrEmpty(image.CameraModel))
{
<strong>Kamera:</strong> @image.CameraDisplay<br />
<span class="d-block">@image.ExifSummary</span>
}
</p> </p>
@if (image.IsOrphaned) @if (image.IsOrphaned)
@ -157,10 +212,13 @@ else
</div> </div>
} }
<div class="d-flex gap-2"> <div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-primary" @onclick="() => ShowReplaceDialog(image)"> <button class="btn btn-sm btn-outline-primary" @onclick="() => ShowReplaceDialog(image)">
Tauschen Tauschen
</button> </button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ShowEditDialog(image)">
Bearbeiten
</button>
@if (image.IsOrphaned) @if (image.IsOrphaned)
{ {
<button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)"> <button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)">
@ -296,6 +354,69 @@ else
</div> </div>
</div> </div>
</div> </div>
</div>
}
@if (_showEditDialog && _editingImage != null)
{
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Bild bearbeiten: @_editingImage.FileName</h5>
<button type="button" class="btn-close" @onclick="CloseEditDialog"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<img src="@_editingImage.Url" class="img-fluid border" alt="@_editingImage.FileName" />
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Autor</label>
<select class="form-select" @bind="_editAuthorId">
<option value="">(keiner)</option>
@foreach (var user in _ghostUsers)
{
<option value="@user.Id">@user.Name</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Thema</label>
<input type="text" class="form-control" @bind="_editTheme" placeholder="z.B. Landschaft, Portrait..." />
</div>
<div class="mb-3">
<label class="form-label">Aufnahmedatum</label>
<InputDate class="form-control" @bind-Value="_editCaptureDate" />
</div>
<div class="alert alert-info small">
<strong>Kamera:</strong> @_editingImage.CameraDisplay<br />
<strong>EXIF:</strong> @_editingImage.ExifSummary<br />
<strong>Dateigröße:</strong> @_editingImage.FormattedFileSize | @_editingImage.Dimensions
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseEditDialog">Abbrechen</button>
<button type="button" class="btn btn-primary" @onclick="SaveEdit" disabled="@_isSavingEdit">
@if (_isSavingEdit)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Speichere...</span>
}
else
{
<span>Speichern</span>
}
</button>
</div>
</div>
</div>
</div> </div>
} }
@ -303,7 +424,10 @@ else
private List<GhostInstance> _instances = new(); private List<GhostInstance> _instances = new();
private string _selectedInstanceId = ""; private string _selectedInstanceId = "";
private string _searchTerm = ""; private string _searchTerm = "";
private string _authorFilter = "";
private string _themeFilter = "";
private List<GhostImage> _allImages = new(); private List<GhostImage> _allImages = new();
private List<GhostImage> _filteredImages = new();
private bool _isLoading; private bool _isLoading;
private bool _hasLoaded; private bool _hasLoaded;
private string? _errorMessage; private string? _errorMessage;
@ -327,6 +451,15 @@ else
private HashSet<string> _selectedImageUrls = new(); private HashSet<string> _selectedImageUrls = new();
private bool _selectAllOrphaned; private bool _selectAllOrphaned;
private List<GhostUser> _ghostUsers = new();
private GhostImage? _editingImage;
private bool _showEditDialog;
private string? _editAuthorId;
private string? _editTheme;
private DateTime? _editCaptureDate;
private bool _isSavingEdit;
protected override void OnInitialized() protected override void OnInitialized()
{ {
var config = ConfigService.Load(); var config = ConfigService.Load();
@ -337,8 +470,11 @@ else
private async Task OnInstanceChanged() private async Task OnInstanceChanged()
{ {
_allImages.Clear(); _allImages.Clear();
_filteredImages.Clear();
_hasLoaded = false; _hasLoaded = false;
_selectedImageUrls.Clear(); _selectedImageUrls.Clear();
_authorFilter = "";
_themeFilter = "";
await LoadImages(); await LoadImages();
} }
@ -355,9 +491,14 @@ else
try try
{ {
var instance = _instances.First(i => i.Id == _selectedInstanceId); var instance = _instances.First(i => i.Id == _selectedInstanceId);
// Ghost-Benutzer laden
_ghostUsers = await GhostApi.GetUsersAsync(instance);
var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'"; var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'";
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter); _allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter);
CalculateCounts(); CalculateCounts();
ApplyFilters();
_hasLoaded = true; _hasLoaded = true;
} }
catch (Exception ex) catch (Exception ex)
@ -379,19 +520,61 @@ else
_contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent)); _contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent));
} }
private List<GhostImage> FilteredImages => _currentFilter switch private List<GhostImage> FilteredImages => _filteredImages;
{
ImageFilter.Orphaned => _allImages.Where(i => i.IsOrphaned).ToList(),
ImageFilter.Feature => _allImages.Where(i => i.UsedInPosts.Any(r => r.IsFeatureImage)).ToList(),
ImageFilter.Content => _allImages.Where(i => i.UsedInPosts.Any(r => r.IsInContent)).ToList(),
_ => _allImages
};
private List<GhostImage> SelectedOrphanedImages => _allImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.IsOrphaned).ToList(); private List<GhostImage> SelectedOrphanedImages => _filteredImages.Where(i => _selectedImageUrls.Contains(i.Url) && i.IsOrphaned).ToList();
private List<KeyValuePair<string, string>> AvailableAuthors => _allImages
.Where(i => !string.IsNullOrEmpty(i.AuthorId))
.GroupBy(i => i.AuthorId!)
.Select(g => new KeyValuePair<string, string>(g.Key, g.First().AuthorName ?? "Unbekannt"))
.OrderBy(kvp => kvp.Value)
.ToList();
private List<string> AvailableThemes => _allImages
.Where(i => !string.IsNullOrEmpty(i.Theme))
.Select(i => i.Theme!)
.Distinct()
.OrderBy(t => t)
.ToList();
private int GetAuthorCount(string authorId) => _allImages.Count(i => i.AuthorId == authorId);
private int GetThemeCount(string theme) => _allImages.Count(i => i.Theme == theme);
private void ApplyFilters()
{
var query = _allImages.AsEnumerable();
if (_currentFilter == ImageFilter.Orphaned)
query = query.Where(i => i.IsOrphaned);
else if (_currentFilter == ImageFilter.Feature)
query = query.Where(i => i.UsedInPosts.Any(r => r.IsFeatureImage));
else if (_currentFilter == ImageFilter.Content)
query = query.Where(i => i.UsedInPosts.Any(r => r.IsInContent));
if (!string.IsNullOrWhiteSpace(_authorFilter))
query = query.Where(i => i.AuthorId == _authorFilter);
if (!string.IsNullOrWhiteSpace(_themeFilter))
query = query.Where(i => i.Theme == _themeFilter);
_filteredImages = query.ToList();
StateHasChanged();
}
private void ResetFilters()
{
_authorFilter = "";
_themeFilter = "";
_searchTerm = "";
_currentFilter = ImageFilter.All;
ApplyFilters();
}
private void SetFilter(ImageFilter filter) private void SetFilter(ImageFilter filter)
{ {
_currentFilter = filter; _currentFilter = filter;
ApplyFilters();
} }
private string GetFilterClass(ImageFilter filter) => _currentFilter == filter ? "active" : ""; private string GetFilterClass(ImageFilter filter) => _currentFilter == filter ? "active" : "";
@ -507,6 +690,8 @@ else
if (success) if (success)
{ {
_selectedImageUrls.Remove(_selectedImage.Url); _selectedImageUrls.Remove(_selectedImage.Url);
if (_selectedImage.ManagedImageId.HasValue)
await DbService.DeleteAsync(_selectedImage.ManagedImageId.Value);
_successMessage = $"{_selectedImage.FileName} gelöscht."; _successMessage = $"{_selectedImage.FileName} gelöscht.";
CloseDeleteDialog(); CloseDeleteDialog();
await LoadImages(); await LoadImages();
@ -522,6 +707,59 @@ else
} }
} }
private void ShowEditDialog(GhostImage image)
{
_editingImage = image;
_editAuthorId = image.AuthorId;
_editTheme = image.Theme;
_editCaptureDate = image.CaptureDate;
_showEditDialog = true;
}
private void CloseEditDialog()
{
_showEditDialog = false;
_editingImage = null;
_editAuthorId = null;
_editTheme = null;
_editCaptureDate = null;
}
private async Task SaveEdit()
{
if (_editingImage?.ManagedImageId == null) return;
_isSavingEdit = true;
StateHasChanged();
try
{
var selectedAuthor = _ghostUsers.FirstOrDefault(u => u.Id == _editAuthorId);
await DbService.UpdateAuthorAsync(_editingImage.ManagedImageId.Value, _editAuthorId, selectedAuthor?.Name);
await DbService.UpdateThemeAsync(_editingImage.ManagedImageId.Value, _editTheme);
await DbService.UpdateCaptureDateAsync(_editingImage.ManagedImageId.Value, _editCaptureDate);
// Lokale Anzeige aktualisieren
_editingImage.AuthorId = _editAuthorId;
_editingImage.AuthorName = selectedAuthor?.Name;
_editingImage.Theme = _editTheme;
_editingImage.CaptureDate = _editCaptureDate;
_successMessage = $"{_editingImage.FileName} aktualisiert.";
CloseEditDialog();
}
catch (Exception ex)
{
Logger.LogError(ex, "Fehler beim Speichern der Bild-Metadaten");
_errorMessage = $"Fehler beim Speichern: {ex.Message}";
}
finally
{
_isSavingEdit = false;
StateHasChanged();
}
}
private async Task LoadNewImage(InputFileChangeEventArgs e) private async Task LoadNewImage(InputFileChangeEventArgs e)
{ {
_newImageFile = e.File; _newImageFile = e.File;

View file

@ -0,0 +1,96 @@
@page "/logs"
@attribute [Authorize]
@rendermode InteractiveServer
@inject LogService LogService
@inject IJSRuntime JSRuntime
<PageTitle>Logs</PageTitle>
<h1>System-Logs</h1>
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">Log-Datei</label>
<select class="form-select" @bind="_selectedFileName" @bind:after="LoadSelectedLog">
@if (_logFiles.Count == 0)
{
<option value="">Keine Logs vorhanden</option>
}
else
{
@foreach (var file in _logFiles)
{
<option value="@Path.GetFileName(file)">@Path.GetFileName(file)</option>
}
}
</select>
</div>
<div class="col-md-2">
<label class="form-label">Zeilen</label>
<select class="form-select" @bind="_maxLines" @bind:after="LoadSelectedLog">
<option value="100">100</option>
<option value="500">500</option>
<option value="1000">1000</option>
<option value="5000">5000</option>
</select>
</div>
<div class="col-md-6 d-flex align-items-end">
<button class="btn btn-outline-secondary me-2" @onclick="LoadSelectedLog">Aktualisieren</button>
<button class="btn btn-outline-primary" @onclick="DownloadLog">Herunterladen</button>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Log-Ausgabe</span>
<span class="badge bg-secondary">@_logContent.Split('\n').Length Zeilen</span>
</div>
<div class="card-body p-0">
<pre class="m-0 p-3 bg-dark text-light" style="max-height: 70vh; overflow: auto; font-size: 0.85rem;"><code>@_logContent</code></pre>
</div>
</div>
@code {
private List<string> _logFiles = new();
private string _selectedFileName = "";
private int _maxLines = 1000;
private string _logContent = "Keine Log-Datei ausgewählt.";
protected override void OnInitialized()
{
LoadLogFiles();
}
private void LoadLogFiles()
{
_logFiles = LogService.GetLogFiles();
if (_logFiles.Any() && string.IsNullOrEmpty(_selectedFileName))
{
_selectedFileName = Path.GetFileName(_logFiles.First());
LoadSelectedLog();
}
}
private void LoadSelectedLog()
{
if (string.IsNullOrEmpty(_selectedFileName))
{
_logContent = "Keine Log-Datei ausgewählt.";
return;
}
_logContent = LogService.ReadLogFile(_selectedFileName, _maxLines);
}
private async Task DownloadLog()
{
if (string.IsNullOrEmpty(_selectedFileName))
return;
var content = LogService.ReadLogFile(_selectedFileName, int.MaxValue);
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
var base64 = Convert.ToBase64String(bytes);
await JSRuntime.InvokeVoidAsync("downloadFileFromBase64", _selectedFileName, base64, "text/plain");
}
}

View file

@ -82,8 +82,8 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Lokaler Image-Ordner</label> <label class="form-label">Lokaler Image-Ordner</label>
<InputText @bind-Value="_editingInstance.ImageFolderPath" class="form-control" placeholder="C:\\Ghost\\content\\images" /> <InputText @bind-Value="_editingInstance.ImageFolderPath" class="form-control" placeholder="C:\\Ghost\\content\\images oder /var/www/ghost/content/images" />
<div class="form-text">Vollständiger Pfad zum Ghost content/images Ordner</div> <div class="form-text">Vollständiger Pfad zum Ghost content/images Ordner (Windows oder Linux)</div>
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">

View file

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
namespace GhostImageManager.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<ManagedImage> ManagedImages => Set<ManagedImage>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ManagedImage>(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);
});
}
}

View file

@ -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; }
}

View file

@ -9,6 +9,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.66" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.66" />
<PackageReference Include="MetadataExtractor" Version="2.8.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.0" />
</ItemGroup> </ItemGroup>

View file

@ -0,0 +1,3 @@
<Solution>
<Project Path="GhostImageManager.csproj" />
</Solution>

View file

@ -2,6 +2,7 @@ namespace GhostImageManager.Models;
public class GhostImage public class GhostImage
{ {
public int? ManagedImageId { get; set; }
public string Url { get; set; } = ""; public string Url { get; set; } = "";
public string FileName { get; set; } = ""; public string FileName { get; set; } = "";
public string RelativePath { get; set; } = ""; public string RelativePath { get; set; } = "";
@ -13,6 +14,21 @@ public class GhostImage
public bool IsOrphaned { get; set; } = true; public bool IsOrphaned { get; set; } = true;
public List<PostReference> UsedInPosts { get; set; } = new(); public List<PostReference> 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 public string FormattedFileSize => FileSize switch
{ {
>= 1024 * 1024 => $"{FileSize / (1024.0 * 1024.0):F2} MB", >= 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 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 public class PostReference

View file

@ -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; }
}

View file

@ -1,14 +1,42 @@
using GhostImageManager.Components; using GhostImageManager.Components;
using GhostImageManager.Data;
using GhostImageManager.Models; using GhostImageManager.Models;
using GhostImageManager.Services; using GhostImageManager.Services;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args); 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. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
// Datenbank
var dbPath = Path.Combine(builder.Environment.ContentRootPath, "ghost-image-manager.db");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings")); builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings"));
builder.Services.AddSingleton<GhostConfigService>(); builder.Services.AddSingleton<GhostConfigService>();
builder.Services.AddScoped<GhostApiService>(); builder.Services.AddScoped<GhostApiService>();
@ -16,7 +44,10 @@ builder.Services.AddScoped<ImageAnalyzerService>();
builder.Services.AddScoped<ImageReplacementService>(); builder.Services.AddScoped<ImageReplacementService>();
builder.Services.AddScoped<ImageDeletionService>(); builder.Services.AddScoped<ImageDeletionService>();
builder.Services.AddScoped<ImageExportService>(); builder.Services.AddScoped<ImageExportService>();
builder.Services.AddScoped<ImageDatabaseService>();
builder.Services.AddScoped<ExifService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddSingleton<LogService>();
builder.Services.AddHttpClient<GhostApiService>(); builder.Services.AddHttpClient<GhostApiService>();
// Authentication // Authentication
@ -41,6 +72,13 @@ builder.WebHost.ConfigureKestrel(options =>
var app = builder.Build(); var app = builder.Build();
// Datenbank sicherstellen
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
@ -73,7 +111,7 @@ app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
// Authentication API endpoints // Authentication API endpoints
app.MapPost("/auth/login", async (HttpContext context, AuthService authService) => app.MapPost("/auth/login", async (HttpContext context, AuthService authService, ILogger<Program> logger) =>
{ {
var form = await context.Request.ReadFormAsync(); var form = await context.Request.ReadFormAsync();
var password = form["password"].ToString(); var password = form["password"].ToString();
@ -84,19 +122,25 @@ app.MapPost("/auth/login", async (HttpContext context, AuthService authService)
try try
{ {
logger.LogInformation("Benutzer meldet sich an");
await authService.SignInAsync(context, password); await authService.SignInAsync(context, password);
logger.LogInformation("Anmeldung erfolgreich");
return Results.Redirect(returnUrl); return Results.Redirect(returnUrl);
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
logger.LogWarning("Fehlgeschlagene Anmeldung");
return Results.Redirect($"/login?errorMessage={Uri.EscapeDataString("Ungültiges Passwort.")}&returnUrl={Uri.EscapeDataString(returnUrl)}"); 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<Program> logger) =>
{ {
logger.LogInformation("Benutzer meldet sich ab");
await authService.SignOutAsync(context); await authService.SignOutAsync(context);
return Results.Redirect("/login"); return Results.Redirect("/login");
}); });
app.Run(); app.Run();
Log.CloseAndFlush();

View file

@ -8,16 +8,25 @@ namespace GhostImageManager.Services;
public class AuthService public class AuthService
{ {
private readonly AppSettings _appSettings; private readonly AppSettings _appSettings;
private readonly ILogger<AuthService> _logger;
public AuthService(IConfiguration configuration) public AuthService(IConfiguration configuration, ILogger<AuthService> logger)
{ {
_appSettings = configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings(); _appSettings = configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();
_logger = logger;
} }
public bool ValidatePassword(string password) public bool ValidatePassword(string password)
{ {
return !string.IsNullOrEmpty(password) && var isValid = !string.IsNullOrEmpty(password) &&
password == _appSettings.Password; password == _appSettings.Password;
if (!isValid)
{
_logger.LogWarning("Passwort-Validierung fehlgeschlagen");
}
return isValid;
} }
public async Task SignInAsync(HttpContext context, string password) public async Task SignInAsync(HttpContext context, string password)

View file

@ -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<ExifService> _logger;
public ExifService(ILogger<ExifService> 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<ExifIfd0Directory>().FirstOrDefault();
var subIfdDirectory = directories.OfType<ExifSubIfdDirectory>().FirstOrDefault();
var exifDirectory = directories.OfType<ExifDirectoryBase>().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;
}
}

View file

@ -1,25 +1,34 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using GhostImageManager.Models; using GhostImageManager.Models;
using HtmlAgilityPack;
namespace GhostImageManager.Services; namespace GhostImageManager.Services;
public class GhostApiService public class GhostApiService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILogger<GhostApiService> _logger;
public GhostApiService(HttpClient httpClient) public GhostApiService(HttpClient httpClient, ILogger<GhostApiService> logger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_logger = logger;
} }
public async Task<List<GhostPost>> GetPostsAsync(GhostInstance instance, string filter = "", int limit = 100) public async Task<List<GhostPost>> 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); var response = await _httpClient.SendAsync(request);
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(); response.EnsureSuccessStatusCode();
}
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json); var doc = JsonDocument.Parse(json);
@ -43,14 +52,23 @@ public class GhostApiService
} }
} }
_logger.LogInformation("{Count} Posts von Ghost geladen", posts.Count);
return posts; return posts;
} }
public async Task<GhostPost?> GetPostAsync(GhostInstance instance, string postId) public async Task<GhostPost?> 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 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); 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 json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json); var doc = JsonDocument.Parse(json);
@ -76,6 +94,8 @@ public class GhostApiService
public async Task<bool> UpdatePostAsync(GhostInstance instance, GhostPost post) public async Task<bool> 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 request = CreateRequest(HttpMethod.Put, instance, $"posts/{post.Id}/?source=html");
var body = new var body = new
{ {
@ -95,11 +115,59 @@ public class GhostApiService
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request); 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<List<GhostUser>> 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<GhostUser>();
}
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var users = new List<GhostUser>();
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<string?> UploadImageAsync(GhostInstance instance, Stream fileStream, string fileName, string? reference = null) public async Task<string?> 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 token = JwtTokenService.GenerateToken(instance.AdminApiKey);
var request = new HttpRequestMessage(HttpMethod.Post, $"{instance.Url.TrimEnd('/')}/ghost/api/admin/images/upload/"); 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); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Ghost", token);
@ -125,15 +193,24 @@ public class GhostApiService
request.Content = content; request.Content = content;
var response = await _httpClient.SendAsync(request); 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 json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json); var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("images", out var images) && images.GetArrayLength() > 0) 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; return null;
} }
@ -161,9 +238,3 @@ public class GhostApiService
return null; return null;
} }
} }
public class GhostImageReference
{
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("ref")] public string? Ref { get; set; }
}

View file

@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using GhostImageManager.Data;
using GhostImageManager.Models; using GhostImageManager.Models;
using HtmlAgilityPack; using HtmlAgilityPack;
@ -7,25 +8,37 @@ namespace GhostImageManager.Services;
public class ImageAnalyzerService public class ImageAnalyzerService
{ {
private readonly GhostApiService _ghostApiService; private readonly GhostApiService _ghostApiService;
private readonly ExifService _exifService;
private readonly ImageDatabaseService _dbService;
private readonly ILogger<ImageAnalyzerService> _logger; private readonly ILogger<ImageAnalyzerService> _logger;
public ImageAnalyzerService(GhostApiService ghostApiService, ILogger<ImageAnalyzerService> logger) public ImageAnalyzerService(
GhostApiService ghostApiService,
ExifService exifService,
ImageDatabaseService dbService,
ILogger<ImageAnalyzerService> logger)
{ {
_ghostApiService = ghostApiService; _ghostApiService = ghostApiService;
_exifService = exifService;
_dbService = dbService;
_logger = logger; _logger = logger;
} }
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "") public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
{ {
var images = new List<GhostImage>(); var images = new List<GhostImage>();
_logger.LogInformation("Starte Bildanalyse für Instanz {InstanceName} ({Url}), Ordner: {Folder}", instance.Name, instance.Url, instance.ImageFolderPath);
if (!Directory.Exists(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; return images;
} }
// 1. Alle Posts abrufen // 1. Alle Posts abrufen
_logger.LogInformation("Rufe Posts mit Filter '{Filter}' ab", searchTerm);
var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm); var posts = await _ghostApiService.GetPostsAsync(instance, searchTerm);
_logger.LogInformation("{Count} Posts geladen, extrahiere Bild-Referenzen", posts.Count);
// 2. Bild-Referenzen aus Posts sammeln // 2. Bild-Referenzen aus Posts sammeln
var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase); var imageReferences = new Dictionary<string, List<PostReference>>(StringComparer.OrdinalIgnoreCase);
@ -79,13 +92,15 @@ public class ImageAnalyzerService
} }
// 3. Lokale Dateien scannen // 3. Lokale Dateien scannen
_logger.LogInformation("Scanne lokalen Image-Ordner: {Folder}", instance.ImageFolderPath);
var localFiles = ScanLocalImageFiles(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) foreach (var file in localFiles)
{ {
var absoluteUrl = ConvertLocalPathToUrl(instance, file.FullName); 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 var image = new GhostImage
{ {
@ -102,6 +117,18 @@ public class ImageAnalyzerService
// Dimensionen ermitteln // Dimensionen ermitteln
GetImageDimensions(image); 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); images.Add(image);
} }
@ -111,21 +138,53 @@ public class ImageAnalyzerService
var localPath = ConvertUrlToLocalPath(instance, url); var localPath = ConvertUrlToLocalPath(instance, url);
if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase))) if (!File.Exists(localPath) && !images.Any(i => i.Url.Equals(url, StringComparison.OrdinalIgnoreCase)))
{ {
images.Add(new GhostImage var externalImage = new GhostImage
{ {
Url = url, Url = url,
FileName = Path.GetFileName(url), FileName = Path.GetFileName(url),
RelativePath = GetRelativePath(instance.ImageFolderPath, localPath).Replace("\\", "/"), RelativePath = GetRelativePath(instance.ImageFolderPath, localPath),
FilePath = localPath, FilePath = localPath,
UsedInPosts = imageReferences[url], UsedInPosts = imageReferences[url],
IsOrphaned = false 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; 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<FileInfo> ScanLocalImageFiles(string folderPath) private List<FileInfo> ScanLocalImageFiles(string folderPath)
{ {
var files = new List<FileInfo>(); var files = new List<FileInfo>();
@ -134,13 +193,16 @@ public class ImageAnalyzerService
if (!Directory.Exists(folderPath)) if (!Directory.Exists(folderPath))
return files; return files;
foreach (var ext in extensions) foreach (var ext in extensions)
{ {
files.AddRange(new DirectoryInfo(folderPath) files.AddRange(new DirectoryInfo(folderPath)
.EnumerateFiles($"*{ext}", SearchOption.AllDirectories)); .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<string> ExtractImageUrls(string html) private List<string> ExtractImageUrls(string html)
@ -164,7 +226,7 @@ public class ImageAnalyzerService
} }
catch (Exception ex) 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(); return urls.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
@ -172,7 +234,7 @@ public class ImageAnalyzerService
private string ConvertLocalPathToUrl(GhostInstance instance, string localPath) 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('/')}"; return $"{instance.Url.TrimEnd('/')}/content/images/{relativePath.TrimStart('/')}";
} }
@ -182,14 +244,17 @@ public class ImageAnalyzerService
if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase)) if (url.StartsWith(contentImagesUrl, StringComparison.OrdinalIgnoreCase))
{ {
var relativePath = url[contentImagesUrl.Length..]; 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)); return Path.Combine(instance.ImageFolderPath, Path.GetFileName(url));
} }
private string GetRelativePath(string basePath, string targetPath) 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 targetUri = new Uri(targetPath);
var relativeUri = baseUri.MakeRelativeUri(targetUri); var relativeUri = baseUri.MakeRelativeUri(targetUri);
return Uri.UnescapeDataString(relativeUri.ToString()); return Uri.UnescapeDataString(relativeUri.ToString());
@ -209,7 +274,7 @@ public class ImageAnalyzerService
} }
catch (Exception ex) 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);
} }
} }
} }

View file

@ -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<ImageDatabaseService> _logger;
public ImageDatabaseService(AppDbContext dbContext, ILogger<ImageDatabaseService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<ManagedImage?> GetByUrlAsync(string ghostImageUrl, string instanceId)
{
return await _dbContext.ManagedImages
.FirstOrDefaultAsync(m => m.GhostImageUrl == ghostImageUrl && m.GhostInstanceId == instanceId);
}
public async Task<List<ManagedImage>> GetByInstanceAsync(string instanceId)
{
return await _dbContext.ManagedImages
.Where(m => m.GhostInstanceId == instanceId)
.OrderBy(m => m.FileName)
.ToListAsync();
}
public async Task<ManagedImage> 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<List<ManagedImage>> 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<List<string>> 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();
}
}

View file

@ -13,6 +13,7 @@ public class ImageDeletionService
public Task<bool> DeleteImageAsync(GhostImage image) public Task<bool> DeleteImageAsync(GhostImage image)
{ {
_logger.LogInformation("Lösche Bild: {FileName} ({FilePath})", image.FileName, image.FilePath);
return Task.FromResult(DeleteFile(image.FilePath)); return Task.FromResult(DeleteFile(image.FilePath));
} }

View file

@ -6,8 +6,16 @@ namespace GhostImageManager.Services;
public class ImageExportService public class ImageExportService
{ {
private readonly ILogger<ImageExportService> _logger;
public ImageExportService(ILogger<ImageExportService> logger)
{
_logger = logger;
}
public byte[] ExportToCsv(List<GhostImage> images) public byte[] ExportToCsv(List<GhostImage> images)
{ {
_logger.LogInformation("Exportiere {Count} Bilder als CSV", images.Count);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("Dateiname;Relativer Pfad;URL;Größe Bytes;Größe;Dimensionen;Letzte Änderung;Verwaist;Verwendet in Posts"); 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<GhostImage> images) public byte[] ExportToTxt(List<GhostImage> images)
{ {
_logger.LogInformation("Exportiere {Count} Bilder als TXT", images.Count);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("Ghost Image Export"); sb.AppendLine("Ghost Image Export");
sb.AppendLine($"Erstellt am: {DateTime.Now:yyyy-MM-dd HH:mm}"); sb.AppendLine($"Erstellt am: {DateTime.Now:yyyy-MM-dd HH:mm}");
@ -51,6 +60,7 @@ public class ImageExportService
public async Task<byte[]> ExportToZipAsync(List<GhostImage> images) public async Task<byte[]> ExportToZipAsync(List<GhostImage> images)
{ {
_logger.LogInformation("Exportiere {Count} Bilder als ZIP", images.Count);
using var ms = new MemoryStream(); using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true)) using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{ {
@ -59,7 +69,7 @@ public class ImageExportService
if (!File.Exists(image.FilePath)) if (!File.Exists(image.FilePath))
continue; continue;
var entry = archive.CreateEntry(image.RelativePath.Replace("/", "\\")); var entry = archive.CreateEntry(image.RelativePath.Replace("\\", "/"));
await using (var entryStream = entry.Open()) await using (var entryStream = entry.Open())
{ {
await using var fileStream = File.OpenRead(image.FilePath); await using var fileStream = File.OpenRead(image.FilePath);

View file

@ -13,6 +13,8 @@ public class ImageReplacementService
public async Task<bool> ReplaceImageAsync(GhostImage oldImage, Stream newImageStream, string newFileName) public async Task<bool> ReplaceImageAsync(GhostImage oldImage, Stream newImageStream, string newFileName)
{ {
_logger.LogInformation("Ersetze Bild {OldFile} durch {NewFile}", oldImage.FileName, newFileName);
try try
{ {
if (!File.Exists(oldImage.FilePath)) if (!File.Exists(oldImage.FilePath))
@ -24,6 +26,7 @@ public class ImageReplacementService
// Backup der alten Datei erstellen // Backup der alten Datei erstellen
var backupPath = oldImage.FilePath + ".backup"; var backupPath = oldImage.FilePath + ".backup";
File.Copy(oldImage.FilePath, backupPath, overwrite: true); File.Copy(oldImage.FilePath, backupPath, overwrite: true);
_logger.LogDebug("Backup erstellt: {BackupPath}", backupPath);
// Neue Datei schreiben // Neue Datei schreiben
using (var fileStream = File.Create(oldImage.FilePath)) using (var fileStream = File.Create(oldImage.FilePath))

View file

@ -1,12 +1,12 @@
{ {
"instances": [ "instances": [
{ {
"id": "prod", "id": "41512d16",
"name": "Produktion", "name": "RBUBB",
"url": "https://www.brigitteundrolandunterwegs.com", "url": "https://rbubb.rolandbrunner.me",
"adminApiKey": "HIER_ADMIN_API_KEY_EINGEBEN", "adminApiKey": "6aa18e1e05dde700011bc18a:f32a3467ec3351ebe693be411f1cfc4be063ed85b467f157e67bdc750c1aa251",
"contentApiKey": "", "contentApiKey": "467ee6c8bf369a09cc57446256",
"imageFolderPath": "C:\\Ghost\\content\\images" "imageFolderPath": "E:\\Wp2Ghost\\images"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 KiB

After

Width:  |  Height:  |  Size: 78 KiB