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