using System.Globalization; using System.IO.Compression; using System.Text; using GerbilManagerWebAPI.Models; using Microsoft.Data.Sqlite; namespace GerbilManagerWebAPI.Import.Rpro3 { /// /// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte /// `_rpro3.db` direkt und überführt sie in . /// /// KRITISCHE PARSING-DETAILS (Port von tools/import/compare_rpro3.py): /// 1. DATUM: _BIRTH/_AM/_DATE/_TODAM/_ABAM sind astronomische Julianische Tageszahlen (REAL). /// 0/None = unbekannt. date = fromordinal(round(jdn) - 1721425). /// 2. ENCODING (gemischt!): TEXT teils UTF-8 (neuer), teils CP1252 (älter). Wir lesen jede /// Spalte als BLOB (Bytes) und dekodieren erst strikt UTF-8, bei Fehler CP1252 /// (siehe smart_decode). Microsoft.Data.Sqlite würde TEXT sonst als UTF-8 erzwingen → Mojibake. /// 3. ID-NAMENSRÄUME: "N"=stamm_tb, "uN"=fremd_tb, "XjY"=wurftier_tb. /// public sealed class Rpro3Reader { private static bool _cp1252Registered; public Rpro3Reader() { // CP1252 ist auf .NET Core nicht ohne CodePagesEncodingProvider verfügbar. if (!_cp1252Registered) { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); _cp1252Registered = true; } } /// Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und /// parst sie. Wirft , wenn keine DB gefunden wurde. /// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn). public Rpro3Data ReadFromBackup(Stream backupStream, out string workDir) { workDir = Path.Combine(Path.GetTempPath(), "rpro3-import-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workDir); try { using var archive = new ZipArchive(backupStream, ZipArchiveMode.Read, leaveOpen: true); var dbEntry = archive.Entries.FirstOrDefault(e => e.Name.Equals("_rpro3.db", StringComparison.OrdinalIgnoreCase)) ?? archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".db", StringComparison.OrdinalIgnoreCase)); if (dbEntry is null) throw new Rpro3FormatException( "In der hochgeladenen Datei wurde keine RennmausPro-Datenbank (_rpro3.db) gefunden."); var dbPath = Path.Combine(workDir, "_rpro3.db"); dbEntry.ExtractToFile(dbPath, overwrite: true); return ReadFromDbFile(dbPath); } catch (InvalidDataException ex) { throw new Rpro3FormatException( "Die Datei ist kein gültiges RennmausPro-Backup (kein lesbares ZIP-Archiv).", ex); } } /// Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad). public Rpro3Data ReadFromDbFile(string dbPath) { var connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadOnly, }.ToString(); using var conn = new SqliteConnection(connectionString); conn.Open(); return Parse(conn); } // ---------- Decoding helpers (Port von smart_decode / jdn_to_date) ---------- /// RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252. internal static string? SmartDecode(byte[]? bytes) { if (bytes is null || bytes.Length == 0) return bytes is null ? null : ""; try { return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) .GetString(bytes); } catch (DecoderFallbackException) { return Encoding.GetEncoding(1252).GetString(bytes); } } /// JDN (REAL) → DateOnly. 0/None/ungültig = null. internal static DateOnly? JdnToDate(double? jdn) { if (jdn is null || jdn.Value <= 0) return null; // Python: date.fromordinal(round(jdn) - 1721425). .NET-Ordinaltage zählen ab 0001-01-01 // mit DateOnly.FromDayNumber(n) wobei DayNumber 0 = 0001-01-01 (= Python-ordinal 1). // → dayNumber = round(jdn) - 1721425 - 1. long dayNumber = (long)Math.Round(jdn.Value) - 1721425 - 1; if (dayNumber < 0 || dayNumber > DateOnly.MaxValue.DayNumber) return null; return DateOnly.FromDayNumber((int)dayNumber); } private static Gender ParseGender(string? sex) => (sex ?? "").Trim().ToLowerInvariant() switch { "männlich" or "maennlich" or "m" or "male" => Gender.male, "weiblich" or "w" or "f" or "female" => Gender.female, _ => Gender.unknown, }; // ---------- BLOB-aware accessors ---------- private static string? Str(SqliteDataReader r, int ord) { if (r.IsDBNull(ord)) return null; // Als Bytes lesen → smart decode (UTF-8/CP1252). using var stream = r.GetStream(ord); using var ms = new MemoryStream(); stream.CopyTo(ms); return SmartDecode(ms.ToArray()); } private static double? Real(SqliteDataReader r, int ord) { if (r.IsDBNull(ord)) return null; try { return r.GetDouble(ord); } catch { return double.TryParse(Str(r, ord), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null; } } private static int? IntOrNull(SqliteDataReader r, int ord) { if (r.IsDBNull(ord)) return null; try { return (int)r.GetInt64(ord); } catch { var s = Str(r, ord); return int.TryParse(s, out var v) ? v : null; } } private static decimal? DecimalOrNull(SqliteDataReader r, int ord) { if (r.IsDBNull(ord)) return null; try { return (decimal)r.GetDouble(ord); } catch { var s = Str(r, ord); return decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : null; } } // Map column name → ordinal once per query (RPRO3 column order is stable but we stay safe). private static Func OrdinalLookup(SqliteDataReader r) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < r.FieldCount; i++) map[r.GetName(i)] = i; return (rr, name) => map.TryGetValue(name, out var o) ? o : -1; } // ---------- Parse ---------- private Rpro3Data Parse(SqliteConnection conn) { var data = new Rpro3Data(); // herk_tb → Anzeigename + voller Kontakt var herkName = new Dictionary(); foreach (var (id, c) in ReadContacts(conn, "herk_tb", "herk", "_HERK_IGNORED")) { data.HerkContacts[c.Id.ToString()] = c; herkName[id] = c.DisplayName; } foreach (var (_, c) in ReadContacts(conn, "abn_tb", "abn", "_ABN_IGNORED")) data.AbnContacts[c.Id.ToString()] = c; // baum_tb: master parent table (id → Mutter/Vater im Namensraum) var baum = new Dictionary(); Query(conn, "SELECT id, _MID, _PID FROM baum_tb", (r, ord) => { var id = Str(r, ord(r, "id")); if (id is not null) baum[id] = (Str(r, ord(r, "_MID")), Str(r, ord(r, "_PID"))); }); // Farben: color_tb (stamm), wurfcolor_tb (pup), fremdcolor_tb (extern) var colorStamm = ReadColors(conn, "color_tb"); var colorPup = ReadColors(conn, "wurfcolor_tb"); var colorExt = ReadColors(conn, "fremdcolor_tb"); data.ColorStammCount = colorStamm.Count; data.ColorExtCount = colorExt.Count; (string farbe, string fcode) ColorFor(string rid) { Dictionary src = rid.StartsWith('u') ? colorExt : rid.Contains('j') ? colorPup : colorStamm; return src.TryGetValue(rid, out var v) ? v : ("", ""); } // Tod (stamm/fremd nutzen tod_tb via tid = stamm-id; fremd hat eigenes _TODAM) var tod = new Dictionary(); Query(conn, "SELECT tid, _AM, _WARUM FROM tod_tb", (r, ord) => { var tid = Str(r, ord(r, "tid")); if (tid is not null) tod[tid] = (JdnToDate(Real(r, ord(r, "_AM"))), Str(r, ord(r, "_WARUM"))); }); // stamm_tb: Namen für Eltern-Auflösung var stammName = new Dictionary(); var fremdName = new Dictionary(); var pupName = new Dictionary(); // stamm_tb (eigene Tiere) Query(conn, "SELECT id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_KASTRAT_DATE FROM stamm_tb", (r, ord) => { var sid = IntOrNull(r, ord(r, "id")); if (sid is null) return; var rid = sid.Value.ToString(); var name = Str(r, ord(r, "_NAME")); stammName[sid.Value] = name; var (farbe, fcode) = ColorFor(rid); baum.TryGetValue(rid, out var par); var herkId = IntOrNull(r, ord(r, "_HERKUNFT")); tod.TryGetValue(rid, out var death); data.Animals.Add(new Rpro3Animal { Rid = rid, Src = Rpro3Src.Stamm, Name = name, Gender = ParseGender(Str(r, ord(r, "_SEX"))), Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))), Farbe = farbe, Fcode = fcode, Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "", OriginHerkId = herkId, Zb = Str(r, ord(r, "_ZB")), MidRaw = par.mid, PidRaw = par.pid, DateOfDeath = death.am, CauseOfDeath = death.warum, Defects = Str(r, ord(r, "_FEHLER")), IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null, StatusCode = IntOrNull(r, ord(r, "_STATUS")), }); }); // fremd_tb (externe Ahnen) Query(conn, "SELECT id,_NAME,_SEX,_BIRTH,_ZB,_HERK,_MID,_PID,_TODAM,_KASTRAT_DATE FROM fremd_tb", (r, ord) => { var fid = IntOrNull(r, ord(r, "id")); if (fid is null) return; var rid = "u" + fid.Value; var name = Str(r, ord(r, "_NAME")); fremdName[fid.Value] = name; var (farbe, fcode) = ColorFor(rid); var herkId = IntOrNull(r, ord(r, "_HERK")); data.Animals.Add(new Rpro3Animal { Rid = rid, Src = Rpro3Src.Fremd, Name = name, Gender = ParseGender(Str(r, ord(r, "_SEX"))), Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))), Farbe = farbe, Fcode = fcode, Origin = herkId is not null && herkName.TryGetValue(herkId.Value, out var hn) ? hn : "", OriginHerkId = herkId, Zb = Str(r, ord(r, "_ZB")), MidRaw = Str(r, ord(r, "_MID")), PidRaw = Str(r, ord(r, "_PID")), DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))), IsCastrated = JdnToDate(Real(r, ord(r, "_KASTRAT_DATE"))) is not null, }); }); // wurf_tb (Würfe) Query(conn, "SELECT id,_MID,_PID,_AM,_BEZ,_BEM FROM wurf_tb", (r, ord) => { var wid = IntOrNull(r, ord(r, "id")); if (wid is null) return; data.Litters[wid.Value] = new Rpro3Litter { Id = wid.Value, Name = Str(r, ord(r, "_BEZ")), Date = JdnToDate(Real(r, ord(r, "_AM"))), MotherRid = Str(r, ord(r, "_MID")), FatherRid = Str(r, ord(r, "_PID")), Notes = Str(r, ord(r, "_BEM")), }; }); // wurftier_tb (Welpen) Query(conn, "SELECT id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_WARUM,_ABAM,_ABN,_PRICE,_ZB,_FEHLER FROM wurftier_tb", (r, ord) => { var id = Str(r, ord(r, "id")); if (id is null) return; var name = Str(r, ord(r, "_NAME")); pupName[id] = name; data.Pups[id] = new Rpro3Pup { Id = id, WurfId = IntOrNull(r, ord(r, "_WID")) ?? 0, Name = name, Gender = ParseGender(Str(r, ord(r, "_SEX"))), Dob = JdnToDate(Real(r, ord(r, "_BIRTH"))), Sid = Str(r, ord(r, "_SID")), DateOfDeath = JdnToDate(Real(r, ord(r, "_TODAM"))), CauseOfDeath = Str(r, ord(r, "_WARUM")), AbgabeDate = JdnToDate(Real(r, ord(r, "_ABAM"))), AbnId = IntOrNull(r, ord(r, "_ABN")), Price = DecimalOrNull(r, ord(r, "_PRICE")), Zb = Str(r, ord(r, "_ZB")), Defects = Str(r, ord(r, "_FEHLER")), }; }); // Eltern-Namen auflösen (für Report / Provenance) string? NameFor(string? rid) { if (string.IsNullOrWhiteSpace(rid) || rid is "n" or "NULL") return null; if (rid.StartsWith('u')) return int.TryParse(rid[1..], out var fid) && fremdName.TryGetValue(fid, out var n) ? n : null; if (rid.Contains('j')) return pupName.TryGetValue(rid, out var n) ? n : null; return int.TryParse(rid, out var sid) && stammName.TryGetValue(sid, out var sn) ? sn : null; } data.ResolveName = NameFor; // Gewichte: waage_tb (_TID = stamm-id) + jungwaage_tb (_TID = wurftier-id "XjY" oder via _WID) Query(conn, "SELECT _TID,_GRAMM,_DATE,_BEM FROM waage_tb", (r, ord) => { var tid = Str(r, ord(r, "_TID")); if (tid is null) return; data.Weights.Add(new Rpro3Weight { Tid = tid, Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0), Date = JdnToDate(Real(r, ord(r, "_DATE"))), Notes = Str(r, ord(r, "_BEM")), }); }); Query(conn, "SELECT _TID,_WID,_GRAMM,_DATE,_BEM FROM jungwaage_tb", (r, ord) => { // _TID ist hier i. d. R. die laufende Pup-Nr im Wurf; _WID der Wurf → "WIDjTID". var tidRaw = Str(r, ord(r, "_TID")); var widRaw = IntOrNull(r, ord(r, "_WID")); string? tid = tidRaw is not null && tidRaw.Contains('j') ? tidRaw : widRaw is not null && tidRaw is not null ? $"{widRaw}j{tidRaw}" : null; if (tid is null) return; data.Weights.Add(new Rpro3Weight { Tid = tid, Grams = (int)Math.Round(Real(r, ord(r, "_GRAMM")) ?? 0), Date = JdnToDate(Real(r, ord(r, "_DATE"))), Notes = Str(r, ord(r, "_BEM")), }); }); // Krankheiten: krank_tb (_TID = stamm-id) Query(conn, "SELECT _TID,_DATE,_BEM,_MED FROM krank_tb", (r, ord) => { var tid = Str(r, ord(r, "_TID")); if (tid is null) return; data.Health.Add(new Rpro3Health { Tid = tid, Date = JdnToDate(Real(r, ord(r, "_DATE"))), Description = Str(r, ord(r, "_BEM")), Medication = Str(r, ord(r, "_MED")), }); }); // Tagebuch: diary_tb (_TID = stamm-id) Query(conn, "SELECT _TID,_BEZ,_DATE,_DESC FROM diary_tb", (r, ord) => { var tid = Str(r, ord(r, "_TID")); if (tid is null) return; data.Diary.Add(new Rpro3Diary { Tid = tid, Title = Str(r, ord(r, "_BEZ")), Date = JdnToDate(Real(r, ord(r, "_DATE"))), Description = Str(r, ord(r, "_DESC")), }); }); // Fotos: photo_tb (id = stamm-id, _P1/_P2/_P3 = Bilddateinamen) Query(conn, "SELECT id,_P1,_P2,_P3 FROM photo_tb", (r, ord) => { var pid = IntOrNull(r, ord(r, "id")); if (pid is null) return; var set = new Rpro3PhotoSet { Tid = pid.Value.ToString() }; foreach (var col in new[] { "_P1", "_P2", "_P3" }) { var fn = Str(r, ord(r, col)); if (!string.IsNullOrWhiteSpace(fn) && fn != "---") set.FileNames.Add(Path.GetFileName(fn.Replace('\\', '/'))); } if (set.FileNames.Count > 0) data.Photos[set.Tid] = set; }); return data; } private static Dictionary ReadColors(SqliteConnection conn, string table) { var dict = new Dictionary(); Query(conn, $"SELECT id,_FARBE,_FCODE FROM {table}", (r, ord) => { var id = Str(r, ord(r, "id")); if (id is not null) dict[id] = (Str(r, ord(r, "_FARBE")) ?? "", Str(r, ord(r, "_FCODE")) ?? ""); }); return dict; } private static IEnumerable<(int id, Rpro3Contact c)> ReadContacts( SqliteConnection conn, string table, string ns, string _) { var list = new List<(int, Rpro3Contact)>(); Query(conn, $"SELECT id,_BEZ,_ANREDE,_VNAME,_NNAME,_CLAN,_STR,_ORT,_PLZ,_TELP,_TELD,_MAIL,_HTTP,_MEMO,_BEM FROM {table}", (r, ord) => { var id = IntOrNull(r, ord(r, "id")); if (id is null) return; list.Add((id.Value, new Rpro3Contact { Id = id.Value, Namespace = ns, Bez = Str(r, ord(r, "_BEZ")), Anrede = Str(r, ord(r, "_ANREDE")), Vname = Str(r, ord(r, "_VNAME")), Nname = Str(r, ord(r, "_NNAME")), Clan = Str(r, ord(r, "_CLAN")), Str = Str(r, ord(r, "_STR")), Ort = Str(r, ord(r, "_ORT")), Plz = Str(r, ord(r, "_PLZ")), Telp = Str(r, ord(r, "_TELP")), Teld = Str(r, ord(r, "_TELD")), Mail = Str(r, ord(r, "_MAIL")), Http = Str(r, ord(r, "_HTTP")), Memo = Str(r, ord(r, "_MEMO")), Bem = Str(r, ord(r, "_BEM")), })); }); return list; } private static void Query( SqliteConnection conn, string sql, Action> onRow) { using var cmd = conn.CreateCommand(); cmd.CommandText = sql; using var r = cmd.ExecuteReader(); Func? ord = null; while (r.Read()) { ord ??= OrdinalLookup(r); onRow(r, ord); } } } public sealed class Rpro3FormatException : Exception { public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { } } }