diff --git a/GerbilManager.Tests/ExportTests.cs b/GerbilManager.Tests/ExportTests.cs
new file mode 100644
index 0000000..3e56061
--- /dev/null
+++ b/GerbilManager.Tests/ExportTests.cs
@@ -0,0 +1,144 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using GerbilManagerWebAPI.Export;
+using GerbilManagerWebAPI.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GerbilManager.Tests
+{
+ ///
+ /// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
+ /// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
+ ///
+ public class ExportTests
+ {
+ // ── CSV-Escaping ───────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ [InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten
+ [InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten
+ [InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln
+ [InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten
+ [InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV)
+ public void Escape_behandelt_Sonderfälle(string? input, string expected)
+ => Assert.Equal(expected, ExportService.Escape(input));
+
+ [Fact]
+ public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen()
+ {
+ var csv = ExportService.BuildCsv(
+ ["Name", "Notizen"],
+ [["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]);
+
+ var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal("Name;Notizen", lines[0]);
+ Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]);
+ Assert.Equal("Bo;", lines[2]);
+ }
+
+ // ── Round-Trip über die API ────────────────────────────────────────
+
+ [Fact]
+ public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten()
+ {
+ using var factory = new ApiFactory();
+
+ // Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht
+ Guid gerbilId;
+ using (var scope = factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" };
+ var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" };
+ var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 };
+ var colorVariety = db.ColorVarieties.First(); // HasData-Seed
+ var gerbil = new Gerbil
+ {
+ Id = Guid.NewGuid(),
+ Name = "Krümel",
+ Gender = Gender.female,
+ Status = GerbilStatus.Active,
+ DateOfBirth = new DateOnly(2025, 3, 12),
+ LitterId = litter.Id,
+ EnclosureId = enclosure.Id,
+ OriginContactId = contact.Id,
+ ColorVarietyId = colorVariety.Id,
+ Genotype = "Aa CC Dd EE GG Pp Spsp rere",
+ ImportSource = "Stammbaum von Akio Kids.xlsx",
+ };
+ gerbilId = gerbil.Id;
+ db.AddRange(contact, enclosure, litter, gerbil,
+ new HealthRecord
+ {
+ Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15),
+ Type = HealthRecordType.Vaccination, Description = "Jahresimpfung",
+ CreatedAt = DateTimeOffset.UtcNow,
+ },
+ new WeightRecord
+ {
+ Id = Guid.NewGuid(), GerbilId = gerbil.Id,
+ Date = new DateOnly(2026, 5, 1), WeightGrams = 78,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ var client = factory.CreateClient();
+ var response = await client.GetAsync("/export");
+ response.EnsureSuccessStatusCode();
+ Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType);
+ Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName);
+
+ using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read);
+ string[] expectedEntries =
+ ["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv",
+ "gesundheit.csv", "gewichte.csv", "LIESMICH.txt"];
+ foreach (var name in expectedEntries)
+ Assert.NotNull(zip.GetEntry(name));
+
+ // export.json: Zähler + volle Treue (Genotyp, Import-Herkunft)
+ using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _));
+ var root = json.RootElement;
+ Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
+ Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
+ Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
+ Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
+ var g = root.GetProperty("gerbils")[0];
+ Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
+ Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
+ Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString());
+
+ // tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum
+ var tiere = ReadEntry(zip, "tiere.csv", out var hadBom);
+ Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel");
+ var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
+ Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]);
+ var row = lines[1];
+ Assert.Contains("Krümel", row);
+ Assert.Contains("Weiblich", row);
+ Assert.Contains("Aktiv", row);
+ Assert.Contains("12.03.2025", row);
+ Assert.Contains("Großbecken", row);
+ Assert.Contains("Wurf K", row);
+
+ // kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen)
+ var kontakte = ReadEntry(zip, "kontakte.csv", out _);
+ Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte);
+
+ // LIESMICH erklärt den Foto-Speicherort
+ Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _));
+ }
+
+ private static string ReadEntry(ZipArchive zip, string name, out bool hadBom)
+ {
+ using var stream = zip.GetEntry(name)!.Open();
+ using var ms = new MemoryStream();
+ stream.CopyTo(ms);
+ var bytes = ms.ToArray();
+ hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
+ return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0));
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
new file mode 100644
index 0000000..7f0ea52
--- /dev/null
+++ b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
@@ -0,0 +1,36 @@
+using GerbilManagerWebAPI.Export;
+using Microsoft.EntityFrameworkCore;
+
+namespace GerbilManagerWebAPI.Endpoints
+{
+ ///
+ /// EXPORT-1: GET /export — komplette Datensicherung als Zip
+ /// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
+ ///
+ public static class ExportEndpoints
+ {
+ public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
+ {
+ app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
+ {
+ var data = new ExportService.ExportData(
+ await db.Gerbils.AsNoTracking().ToListAsync(ct),
+ await db.Litters.AsNoTracking().ToListAsync(ct),
+ await db.Contacts.AsNoTracking().ToListAsync(ct),
+ await db.Enclosures.AsNoTracking().ToListAsync(ct),
+ await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
+ await db.HealthRecords.AsNoTracking().ToListAsync(ct),
+ await db.WeightRecords.AsNoTracking().ToListAsync(ct),
+ await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
+
+ var today = DateOnly.FromDateTime(DateTime.Now);
+ var bytes = ExportService.BuildZip(data, today);
+ return Results.File(bytes, "application/zip",
+ $"rennmaus-export-{today:yyyy-MM-dd}.zip");
+ })
+ .WithTags("Export");
+
+ return app;
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Export/ExportService.cs b/GerbilManagerWebAPI/Export/ExportService.cs
new file mode 100644
index 0000000..9f83ff5
--- /dev/null
+++ b/GerbilManagerWebAPI/Export/ExportService.cs
@@ -0,0 +1,212 @@
+using System.Globalization;
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GerbilManagerWebAPI.Models;
+
+namespace GerbilManagerWebAPI.Export
+{
+ ///
+ /// EXPORT-1: user-facing Datenexport — builds a zip containing
+ /// - export.json (full fidelity: every entity incl. genotypes + import provenance)
+ /// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
+ /// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
+ /// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
+ ///
+ /// Pure function over (no DbContext) so the round-trip
+ /// and escaping tests run without a database.
+ ///
+ public static class ExportService
+ {
+ public sealed record ExportData(
+ List Gerbils,
+ List Litters,
+ List Contacts,
+ List Enclosures,
+ List ColorVarieties,
+ List HealthRecords,
+ List WeightRecords,
+ List Photos);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ WriteIndented = true,
+ ReferenceHandler = ReferenceHandler.IgnoreCycles,
+ };
+
+ // German labels for enum values (CSV is the wife-facing backup format;
+ // the JSON keeps the enum member names for lossless re-import).
+ private static readonly Dictionary GenderDe = new()
+ {
+ [Gender.unknown] = "Unbekannt",
+ [Gender.male] = "Männlich",
+ [Gender.female] = "Weiblich",
+ };
+
+ private static readonly Dictionary StatusDe = new()
+ {
+ [GerbilStatus.Active] = "Aktiv",
+ [GerbilStatus.Deceased] = "Verstorben",
+ [GerbilStatus.GivenAway] = "Abgegeben",
+ };
+
+ private static readonly Dictionary HealthTypeDe = new()
+ {
+ [HealthRecordType.Examination] = "Untersuchung",
+ [HealthRecordType.Treatment] = "Behandlung",
+ [HealthRecordType.Injury] = "Verletzung",
+ [HealthRecordType.Vaccination] = "Impfung",
+ [HealthRecordType.Other] = "Sonstiges",
+ };
+
+ public static byte[] BuildZip(ExportData data, DateOnly exportDate)
+ {
+ var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
+ var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
+ var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
+ var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
+ var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
+
+ string? Lookup(Dictionary map, TKey? key) where TKey : struct =>
+ key is null ? null : map.GetValueOrDefault(key.Value);
+
+ using var stream = new MemoryStream();
+ using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ AddText(zip, "export.json", JsonSerializer.Serialize(new
+ {
+ exportedAt = exportDate.ToString("yyyy-MM-dd"),
+ data.Gerbils,
+ data.Litters,
+ data.Contacts,
+ data.Enclosures,
+ data.ColorVarieties,
+ data.HealthRecords,
+ data.WeightRecords,
+ data.Photos,
+ }, JsonOptions), bom: false);
+
+ AddCsv(zip, "tiere.csv",
+ ["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
+ "Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
+ "Genotyp", "Notizen"],
+ data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
+ {
+ g.Name, GenderDe[g.Gender], StatusDe[g.Status],
+ De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
+ De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
+ Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
+ Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
+ g.Genotype, g.Notes,
+ }));
+
+ AddCsv(zip, "wuerfe.csv",
+ ["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
+ "Voraussichtliches Abgabedatum", "Notizen"],
+ data.Litters.OrderBy(l => l.Date).Select(l => new[]
+ {
+ l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
+ l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
+ De(l.ExpectedGoHomeDate), l.Notes,
+ }));
+
+ AddCsv(zip, "kontakte.csv",
+ ["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
+ data.Contacts.OrderBy(c => c.Name).Select(c => new[]
+ {
+ c.Name, c.Email, c.Phone, c.Address, c.Notes,
+ }));
+
+ AddCsv(zip, "gesundheit.csv",
+ ["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
+ data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
+ {
+ gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
+ HealthTypeDe[h.Type], h.Description, h.Veterinarian,
+ }));
+
+ AddCsv(zip, "gewichte.csv",
+ ["Tier", "Datum", "Gewicht (g)", "Notizen"],
+ data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
+ {
+ gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
+ w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
+ }));
+
+ AddText(zip, "LIESMICH.txt", $"""
+ Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
+
+ Enthalten:
+ - export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
+ Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
+ Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
+ - tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
+ - wuerfe.csv : alle Würfe.
+ - kontakte.csv : alle Kontakte.
+ - gesundheit.csv: alle Gesundheitseinträge.
+ - gewichte.csv : alle Gewichtseinträge.
+
+ Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
+ deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
+
+ FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
+ normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
+ neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
+ von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
+ steht in export.json (Abschnitt "photos").
+ """, bom: false);
+ }
+ return stream.ToArray();
+ }
+
+ // ── CSV building blocks ────────────────────────────────────────────
+
+ /// "2024-03-12" -> "12.03.2024"; null -> empty.
+ private static string? De(DateOnly? date) =>
+ date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
+
+ private static string De(DateOnly date) =>
+ date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
+
+ ///
+ /// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
+ /// they contain separator/quote/newline; quotes doubled.
+ ///
+ internal static string BuildCsv(string[] header, IEnumerable rows)
+ {
+ var sb = new StringBuilder();
+ sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
+ foreach (var row in rows)
+ sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
+ return sb.ToString();
+ }
+
+ internal static string Escape(string? field)
+ {
+ if (string.IsNullOrEmpty(field)) return "";
+ return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
+ ? $"\"{field.Replace("\"", "\"\"")}\""
+ : field;
+ }
+
+ private static void AddCsv(ZipArchive zip, string name, string[] header,
+ IEnumerable rows) =>
+ AddText(zip, name, BuildCsv(header, rows), bom: true);
+
+ private static void AddText(ZipArchive zip, string name, string content, bool bom)
+ {
+ var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
+ using var entryStream = entry.Open();
+ if (bom)
+ {
+ // UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
+ entryStream.Write(Encoding.UTF8.GetPreamble());
+ }
+ var bytes = Encoding.UTF8.GetBytes(content);
+ entryStream.Write(bytes);
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
index af6567e..cd572d9 100644
--- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
+++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
@@ -30,4 +30,9 @@
+
+
+
+
+
diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs
index 897d765..da1a4a6 100644
--- a/GerbilManagerWebAPI/Program.cs
+++ b/GerbilManagerWebAPI/Program.cs
@@ -64,6 +64,7 @@ app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
+app.MapExportEndpoints();
app.Run();