- ghost admin
This commit is contained in:
parent
00d2681a70
commit
a730639c2a
22 changed files with 1036 additions and 56 deletions
56
.gitignore
vendored
56
.gitignore
vendored
|
|
@ -6,3 +6,59 @@
|
|||
/GhostImageManager/bin/*
|
||||
/GhostImageManager/obj/*
|
||||
/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
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@
|
|||
<span class="bi bi-images" aria-hidden="true"></span> Bilder
|
||||
</NavLink>
|
||||
</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">
|
||||
<NavLink class="nav-link" href="settings">
|
||||
<span class="bi bi-gear-fill" aria-hidden="true"></span> Einstellungen
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inject GhostConfigService ConfigService
|
||||
@inject GhostApiService GhostApi
|
||||
@inject ImageAnalyzerService ImageAnalyzer
|
||||
@inject ImageReplacementService ReplacementService
|
||||
@inject ImageDeletionService DeletionService
|
||||
@inject ImageExportService ExportService
|
||||
@inject ImageDatabaseService DbService
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject ILogger<Images> Logger
|
||||
|
||||
|
|
@ -22,7 +24,7 @@
|
|||
else
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Ghost-Instanz</label>
|
||||
<select class="form-select" @bind="_selectedInstanceId" @bind:after="OnInstanceChanged">
|
||||
@foreach (var instance in _instances)
|
||||
|
|
@ -31,14 +33,37 @@ else
|
|||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Suche nach Post</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</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">
|
||||
@if (_isLoading)
|
||||
{
|
||||
|
|
@ -95,6 +120,19 @@ else
|
|||
<button class="btn btn-outline-danger @GetFilterClass(ImageFilter.Orphaned)" @onclick="() => SetFilter(ImageFilter.Orphaned)">Verwaist (@_orphanedCount)</button>
|
||||
</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="form-check">
|
||||
<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>Größe:</strong> @image.FormattedFileSize<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>
|
||||
|
||||
@if (image.IsOrphaned)
|
||||
|
|
@ -157,10 +212,13 @@ else
|
|||
</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)">
|
||||
Tauschen
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ShowEditDialog(image)">
|
||||
Bearbeiten
|
||||
</button>
|
||||
@if (image.IsOrphaned)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => ShowDeleteDialog(image)">
|
||||
|
|
@ -295,6 +353,69 @@ else
|
|||
</button>
|
||||
</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>
|
||||
}
|
||||
|
|
@ -303,7 +424,10 @@ else
|
|||
private List<GhostInstance> _instances = new();
|
||||
private string _selectedInstanceId = "";
|
||||
private string _searchTerm = "";
|
||||
private string _authorFilter = "";
|
||||
private string _themeFilter = "";
|
||||
private List<GhostImage> _allImages = new();
|
||||
private List<GhostImage> _filteredImages = new();
|
||||
private bool _isLoading;
|
||||
private bool _hasLoaded;
|
||||
private string? _errorMessage;
|
||||
|
|
@ -327,6 +451,15 @@ else
|
|||
private HashSet<string> _selectedImageUrls = new();
|
||||
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()
|
||||
{
|
||||
var config = ConfigService.Load();
|
||||
|
|
@ -337,8 +470,11 @@ else
|
|||
private async Task OnInstanceChanged()
|
||||
{
|
||||
_allImages.Clear();
|
||||
_filteredImages.Clear();
|
||||
_hasLoaded = false;
|
||||
_selectedImageUrls.Clear();
|
||||
_authorFilter = "";
|
||||
_themeFilter = "";
|
||||
await LoadImages();
|
||||
}
|
||||
|
||||
|
|
@ -355,9 +491,14 @@ else
|
|||
try
|
||||
{
|
||||
var instance = _instances.First(i => i.Id == _selectedInstanceId);
|
||||
|
||||
// Ghost-Benutzer laden
|
||||
_ghostUsers = await GhostApi.GetUsersAsync(instance);
|
||||
|
||||
var filter = string.IsNullOrWhiteSpace(_searchTerm) ? "" : $"title:~'{EscapeFilter(_searchTerm)}'";
|
||||
_allImages = await ImageAnalyzer.AnalyzeImagesAsync(instance, filter);
|
||||
CalculateCounts();
|
||||
ApplyFilters();
|
||||
_hasLoaded = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -379,19 +520,61 @@ else
|
|||
_contentCount = _allImages.Count(i => i.UsedInPosts.Any(r => r.IsInContent));
|
||||
}
|
||||
|
||||
private List<GhostImage> FilteredImages => _currentFilter switch
|
||||
{
|
||||
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> FilteredImages => _filteredImages;
|
||||
|
||||
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)
|
||||
{
|
||||
_currentFilter = filter;
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private string GetFilterClass(ImageFilter filter) => _currentFilter == filter ? "active" : "";
|
||||
|
|
@ -507,6 +690,8 @@ else
|
|||
if (success)
|
||||
{
|
||||
_selectedImageUrls.Remove(_selectedImage.Url);
|
||||
if (_selectedImage.ManagedImageId.HasValue)
|
||||
await DbService.DeleteAsync(_selectedImage.ManagedImageId.Value);
|
||||
_successMessage = $"{_selectedImage.FileName} gelöscht.";
|
||||
CloseDeleteDialog();
|
||||
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)
|
||||
{
|
||||
_newImageFile = e.File;
|
||||
|
|
|
|||
96
GhostImageManager/Components/Pages/Logs.razor
Normal file
96
GhostImageManager/Components/Pages/Logs.razor
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -82,8 +82,8 @@
|
|||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Lokaler Image-Ordner</label>
|
||||
<InputText @bind-Value="_editingInstance.ImageFolderPath" class="form-control" placeholder="C:\\Ghost\\content\\images" />
|
||||
<div class="form-text">Vollständiger Pfad zum Ghost content/images Ordner</div>
|
||||
<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 (Windows oder Linux)</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
|
|
|
|||
41
GhostImageManager/Data/AppDbContext.cs
Normal file
41
GhostImageManager/Data/AppDbContext.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
44
GhostImageManager/Data/ManagedImage.cs
Normal file
44
GhostImageManager/Data/ManagedImage.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
|
||||
<ItemGroup>
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
3
GhostImageManager/GhostImageManager.slnx
Normal file
3
GhostImageManager/GhostImageManager.slnx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<Solution>
|
||||
<Project Path="GhostImageManager.csproj" />
|
||||
</Solution>
|
||||
|
|
@ -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<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
|
||||
{
|
||||
>= 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
|
||||
|
|
|
|||
10
GhostImageManager/Models/GhostUser.cs
Normal file
10
GhostImageManager/Models/GhostUser.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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<AppDbContext>(options =>
|
||||
options.UseSqlite($"Data Source={dbPath}"));
|
||||
|
||||
builder.Services.Configure<AppSettings>(builder.Configuration.GetSection("AppSettings"));
|
||||
builder.Services.AddSingleton<GhostConfigService>();
|
||||
builder.Services.AddScoped<GhostApiService>();
|
||||
|
|
@ -16,7 +44,10 @@ builder.Services.AddScoped<ImageAnalyzerService>();
|
|||
builder.Services.AddScoped<ImageReplacementService>();
|
||||
builder.Services.AddScoped<ImageDeletionService>();
|
||||
builder.Services.AddScoped<ImageExportService>();
|
||||
builder.Services.AddScoped<ImageDatabaseService>();
|
||||
builder.Services.AddScoped<ExifService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddSingleton<LogService>();
|
||||
builder.Services.AddHttpClient<GhostApiService>();
|
||||
|
||||
// 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<AppDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
|
|
@ -73,7 +111,7 @@ app.MapRazorComponents<App>()
|
|||
.AddInteractiveServerRenderMode();
|
||||
|
||||
// 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 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<Program> logger) =>
|
||||
{
|
||||
logger.LogInformation("Benutzer meldet sich ab");
|
||||
await authService.SignOutAsync(context);
|
||||
return Results.Redirect("/login");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
Log.CloseAndFlush();
|
||||
|
|
|
|||
|
|
@ -8,16 +8,25 @@ namespace GhostImageManager.Services;
|
|||
public class AuthService
|
||||
{
|
||||
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();
|
||||
_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)
|
||||
|
|
|
|||
92
GhostImageManager/Services/ExifService.cs
Normal file
92
GhostImageManager/Services/ExifService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GhostApiService> _logger;
|
||||
|
||||
public GhostApiService(HttpClient httpClient)
|
||||
public GhostApiService(HttpClient httpClient, ILogger<GhostApiService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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);
|
||||
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<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 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<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 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<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)
|
||||
{
|
||||
_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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ImageAnalyzerService> _logger;
|
||||
|
||||
public ImageAnalyzerService(GhostApiService ghostApiService, ILogger<ImageAnalyzerService> logger)
|
||||
public ImageAnalyzerService(
|
||||
GhostApiService ghostApiService,
|
||||
ExifService exifService,
|
||||
ImageDatabaseService dbService,
|
||||
ILogger<ImageAnalyzerService> logger)
|
||||
{
|
||||
_ghostApiService = ghostApiService;
|
||||
_exifService = exifService;
|
||||
_dbService = dbService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<GhostImage>> AnalyzeImagesAsync(GhostInstance instance, string searchTerm = "")
|
||||
{
|
||||
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))
|
||||
{
|
||||
_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<string, List<PostReference>>(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<FileInfo> ScanLocalImageFiles(string folderPath)
|
||||
{
|
||||
var files = new List<FileInfo>();
|
||||
|
|
@ -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<string> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
166
GhostImageManager/Services/ImageDatabaseService.cs
Normal file
166
GhostImageManager/Services/ImageDatabaseService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ public class ImageDeletionService
|
|||
|
||||
public Task<bool> DeleteImageAsync(GhostImage image)
|
||||
{
|
||||
_logger.LogInformation("Lösche Bild: {FileName} ({FilePath})", image.FileName, image.FilePath);
|
||||
return Task.FromResult(DeleteFile(image.FilePath));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@ namespace GhostImageManager.Services;
|
|||
|
||||
public class ImageExportService
|
||||
{
|
||||
private readonly ILogger<ImageExportService> _logger;
|
||||
|
||||
public ImageExportService(ILogger<ImageExportService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public byte[] ExportToCsv(List<GhostImage> 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<GhostImage> 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<byte[]> ExportToZipAsync(List<GhostImage> 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);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ public class ImageReplacementService
|
|||
|
||||
public async Task<bool> 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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 720 KiB After Width: | Height: | Size: 78 KiB |
Loading…
Reference in a new issue