feat(import): RennmausPro-III-Backup-Importer (analyze+execute, Dedup, Fotos)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Import.Rpro3;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
@@ -8,6 +11,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
||||
/// against Julian's DB under god-supervision after he approves the dry-run.
|
||||
///
|
||||
/// RPRO3 — vollwertiger RennmausPro-III-Backup-Importer (Upload via "Hilfe"):
|
||||
/// POST /import/rpro3/analyze -> .backup (+ optional bilder.zip) entpacken, parsen, deduplizieren;
|
||||
/// schreibt NICHTS. Liefert Zählungen, Top-Merges, mehrdeutige Namen.
|
||||
/// POST /import/rpro3/execute -> idempotenter Import (deterministische GUIDs).
|
||||
/// </summary>
|
||||
public static class ImportEndpoints
|
||||
{
|
||||
@@ -36,7 +44,109 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
// ── RPRO3 ──────────────────────────────────────────────────────────
|
||||
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new Rpro3Reader();
|
||||
await using var bs = backup.OpenReadStream();
|
||||
using var seekable = await ToSeekable(bs);
|
||||
var data = reader.ReadFromBackup(seekable, out var workDir);
|
||||
try
|
||||
{
|
||||
IReadOnlySet<string>? photoFiles = null;
|
||||
if (images is not null)
|
||||
{
|
||||
await using var imgStream = images.OpenReadStream();
|
||||
using var imgSeekable = await ToSeekable(imgStream);
|
||||
photoFiles = ListZipImageNames(imgSeekable);
|
||||
}
|
||||
var service = new Rpro3ImportService(db, config, env);
|
||||
var result = await service.AnalyzeAsync(data, images is not null, photoFiles);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
finally { TryDeleteDir(workDir); }
|
||||
}
|
||||
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
group.MapPost("/rpro3/execute", async Task<Results<Ok<Rpro3ExecuteResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new Rpro3Reader();
|
||||
await using var bs = backup.OpenReadStream();
|
||||
using var seekable = await ToSeekable(bs);
|
||||
var data = reader.ReadFromBackup(seekable, out var workDir);
|
||||
try
|
||||
{
|
||||
Dictionary<string, string>? photoPaths = null;
|
||||
if (images is not null)
|
||||
{
|
||||
await using var imgStream = images.OpenReadStream();
|
||||
using var imgSeekable = await ToSeekable(imgStream);
|
||||
photoPaths = ExtractZipImages(imgSeekable, workDir);
|
||||
}
|
||||
var service = new Rpro3ImportService(db, config, env);
|
||||
var result = await service.ExecuteAsync(data, workDir, images is not null, photoPaths);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
finally { TryDeleteDir(workDir); }
|
||||
}
|
||||
catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); }
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren.
|
||||
private static async Task<MemoryStream> ToSeekable(Stream s)
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
await s.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
private static readonly string[] ImageExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
|
||||
|
||||
private static IReadOnlySet<string> ListZipImageNames(Stream zip)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
if (!string.IsNullOrEmpty(e.Name) && ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
||||
set.Add(e.Name);
|
||||
return set;
|
||||
}
|
||||
|
||||
// Bilder ins workDir entpacken; Rückgabe: lowercase-Dateiname → absoluter Pfad.
|
||||
private static Dictionary<string, string> ExtractZipImages(Stream zip, string workDir)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var imgDir = Path.Combine(workDir, "bilder");
|
||||
Directory.CreateDirectory(imgDir);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Name) || !ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant()))
|
||||
continue;
|
||||
var dest = Path.Combine(imgDir, Guid.NewGuid().ToString("N") + Path.GetExtension(e.Name));
|
||||
try { e.ExtractToFile(dest, overwrite: true); map[e.Name] = dest; }
|
||||
catch { /* defektes Archiv-Entry überspringen */ }
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void TryDeleteDir(string dir)
|
||||
{
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user