using System.IO.Compression; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Import.Rpro3; using Microsoft.AspNetCore.Http.HttpResults; namespace GerbilManagerWebAPI.Endpoints { /// /// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output. /// 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). /// public static class ImportEndpoints { public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/import").WithTags("Import"); group.MapPost("/dry-run", async Task> ( ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var report = await new ImportService(db, config, env).RunAsync(execute: false); return TypedResults.Ok(report); }); group.MapPost("/execute", async Task> ( ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var report = await new ImportService(db, config, env).RunAsync(execute: true); return TypedResults.Ok(report); }); group.MapPost("/ingest-resolved", async Task> ( ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var result = await new IngestResolvedService(db, config, env).RunAsync(); return TypedResults.Ok(result); }); // ── RPRO3 ────────────────────────────────────────────────────────── group.MapPost("/rpro3/analyze", async Task, BadRequest>> ( 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? 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, BadRequest>> ( 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? 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 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 ListZipImageNames(Stream zip) { var set = new HashSet(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 ExtractZipImages(Stream zip, string workDir) { var map = new Dictionary(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 */ } } } }