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,_BECKEN 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 beckenId = IntOrNull(r, ord(r, "_BECKEN")); if (beckenId is > 0) data.StammBecken[rid] = beckenId.Value; 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; }); ParseFeatureTables(conn, data); return data; } /// Parst die Feature-Tabellen (becken/herktier/abgeben/abstat/getback/nachfrage/ausz). /// Jede Tabelle ist optional — fehlt sie in einem älteren Backup, wird sie still übersprungen. private void ParseFeatureTables(SqliteConnection conn, Rpro3Data data) { // becken_tb: Gehege. QueryOptional(conn, "SELECT id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS FROM becken_tb", (r, ord) => { var id = IntOrNull(r, ord(r, "id")); if (id is null) return; data.Becken.Add(new Rpro3Becken { Id = id.Value, Name = Str(r, ord(r, "_BEZ")), Size = Str(r, ord(r, "_SIZE")), Capacity = IntOrNull(r, ord(r, "_MENGE")), LastCleaned = JdnToDate(Real(r, ord(r, "_CLEANED"))), CycleDays = IntOrNull(r, ord(r, "_CYCLUS")), }); }); // herktier_tb: Erwerb. QueryOptional(conn, "SELECT id,_TID,_DATE,_PRICE,_BEM FROM herktier_tb", (r, ord) => { var id = IntOrNull(r, ord(r, "id")); if (id is null) return; data.Acquisitions.Add(new Rpro3Acquisition { Id = id.Value, Tid = Str(r, ord(r, "_TID")), Date = JdnToDate(Real(r, ord(r, "_DATE"))), Price = DecimalOrNull(r, ord(r, "_PRICE")), Note = Str(r, ord(r, "_BEM")), }); }); // abgeben_tb: Reservierung/Vormerkung. QueryOptional(conn, "SELECT tid,_RES,_TERMIN,_ABN,_BEM FROM abgeben_tb", (r, ord) => { var tid = Str(r, ord(r, "tid")); if (string.IsNullOrWhiteSpace(tid)) return; data.Abgeben.Add(new Rpro3Abgeben { Tid = tid, Reserved = (IntOrNull(r, ord(r, "_RES")) ?? 0) != 0, Appointment = JdnToDate(Real(r, ord(r, "_TERMIN"))), AbnId = IntOrNull(r, ord(r, "_ABN")), Note = Str(r, ord(r, "_BEM")), }); }); // abstat_tb: Abgabe-Abschluss. QueryOptional(conn, "SELECT tid,_IDABN,_AM,_PRICE,_BEM FROM abstat_tb", (r, ord) => { var tid = Str(r, ord(r, "tid")); if (string.IsNullOrWhiteSpace(tid)) return; data.Abstat.Add(new Rpro3Abstat { Tid = tid, AbnId = IntOrNull(r, ord(r, "_IDABN")), HandedOver = JdnToDate(Real(r, ord(r, "_AM"))), Price = DecimalOrNull(r, ord(r, "_PRICE")), Note = Str(r, ord(r, "_BEM")), }); }); // getback_tb: Rücknahme. QueryOptional(conn, "SELECT id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM FROM getback_tb", (r, ord) => { var id = IntOrNull(r, ord(r, "id")); if (id is null) return; data.Getback.Add(new Rpro3Getback { Id = id.Value, Tid = Str(r, ord(r, "_TID")), ReturnDate = JdnToDate(Real(r, ord(r, "_ZAM"))), ReturnPrice = DecimalOrNull(r, ord(r, "_ZPREIS")), OriginalSaleDate = JdnToDate(Real(r, ord(r, "_AM"))), OriginalPrice = DecimalOrNull(r, ord(r, "_PREIS")), AbnId = IntOrNull(r, ord(r, "_ABN")), Note = Str(r, ord(r, "_BEM")), }); }); // nachfrage_tb: Warteliste. _VOM ist das Anfragedatum (_DATE ist hier i. d. R. 0). // Hat keine eigene _BEM-Spalte; _CODE (z. B. "egal") dient als Zusatznotiz. QueryOptional(conn, "SELECT id,_COLOR,_SEX,_ABN,_VOM,_DATE,_STATUS,_CODE FROM nachfrage_tb", (r, ord) => { var id = IntOrNull(r, ord(r, "id")); if (id is null) return; var code = Str(r, ord(r, "_CODE")); data.Nachfrage.Add(new Rpro3Nachfrage { Id = id.Value, WishColor = Str(r, ord(r, "_COLOR")), WishGender = ParseGender(Str(r, ord(r, "_SEX"))), AbnId = IntOrNull(r, ord(r, "_ABN")), RequestedAt = JdnToDate(Real(r, ord(r, "_VOM"))) ?? JdnToDate(Real(r, ord(r, "_DATE"))), Status = Str(r, ord(r, "_STATUS")), Note = string.IsNullOrWhiteSpace(code) || code == "egal" ? null : code, }); }); // ausz_tb: Ausstellungsergebnisse (in vielen Backups leer — robust gegen 0 Zeilen). QueryOptional(conn, "SELECT id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM FROM ausz_tb", (r, ord) => { var id = IntOrNull(r, ord(r, "id")); if (id is null) return; data.Ausstellungen.Add(new Rpro3Ausstellung { Id = id.Value, Tid = Str(r, ord(r, "_TID")), EventName = Str(r, ord(r, "_VERANSTALTUNG")), Date = JdnToDate(Real(r, ord(r, "_DATE"))), Place = Str(r, ord(r, "_ORT")), Placement = Str(r, ord(r, "_PLATZ")), Award = Str(r, ord(r, "_AUSZ")), Juror = Str(r, ord(r, "_JUROR")), Note = Str(r, ord(r, "_BEM")), }); }); } 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); } } /// Wie , ignoriert aber fehlende Tabellen/Spalten /// (ältere Backups ohne die Feature-Tabellen). SQLite-Fehler werden geschluckt. private static void QueryOptional( SqliteConnection conn, string sql, Action> onRow) { try { Query(conn, sql, onRow); } catch (SqliteException) { /* Tabelle/Spalte fehlt in diesem Backup → überspringen */ } } } public sealed class Rpro3FormatException : Exception { public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { } } }