Files
GerbilManager/GerbilManagerWebAPI/Import/Rpro3/Rpro3Reader.cs
Gulum c7b054e909 feat(import): RPRO3-Importer um 6 Feature-Tabellen erweitern
becken→Gehege-Reinigung, herktier→Erwerb, abgeben/abstat→Reservierung,
getback→Rücknahmen, nachfrage→Warteliste, ausz→Ausstellungen.
Deterministische GUIDs, idempotenter Re-Import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:05:01 +02:00

614 lines
27 KiB
C#

using System.Globalization;
using System.IO.Compression;
using System.Text;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
namespace GerbilManagerWebAPI.Import.Rpro3
{
/// <summary>
/// Liest eine RennmausPro-III-`.backup` (ZIP mit `_rpro3.db` + `.mxp`) bzw. eine entpackte
/// `_rpro3.db` direkt und überführt sie in <see cref="Rpro3Data"/>.
///
/// 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.
/// </summary>
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;
}
}
/// <summary>Findet/extrahiert die `_rpro3.db` aus einem Upload-Stream (ZIP/.backup) und
/// parst sie. Wirft <see cref="Rpro3FormatException"/>, wenn keine DB gefunden wurde.
/// Gibt den temporären Arbeitsordner zurück (Caller löscht ihn).</summary>
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);
}
}
/// <summary>Parst eine bereits entpackte `_rpro3.db` direkt (für Tests/Schnellpfad).</summary>
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) ----------
/// <summary>RPRO3-DB ist gemischt kodiert. Erst UTF-8 strikt versuchen, sonst CP1252.</summary>
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);
}
}
/// <summary>JDN (REAL) → DateOnly. 0/None/ungültig = null.</summary>
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<SqliteDataReader, string, int> OrdinalLookup(SqliteDataReader r)
{
var map = new Dictionary<string, int>(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<int, string>();
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<string, (string? mid, string? pid)>();
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<string, (string, string)> 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<string, (DateOnly? am, string? warum)>();
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<int, string?>();
var fremdName = new Dictionary<int, string?>();
var pupName = new Dictionary<string, string?>();
// 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;
}
/// <summary>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.</summary>
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<string, (string farbe, string fcode)> ReadColors(SqliteConnection conn, string table)
{
var dict = new Dictionary<string, (string, string)>();
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<SqliteDataReader, Func<SqliteDataReader, string, int>> onRow)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
using var r = cmd.ExecuteReader();
Func<SqliteDataReader, string, int>? ord = null;
while (r.Read())
{
ord ??= OrdinalLookup(r);
onRow(r, ord);
}
}
/// <summary>Wie <see cref="Query"/>, ignoriert aber fehlende Tabellen/Spalten
/// (ältere Backups ohne die Feature-Tabellen). SQLite-Fehler werden geschluckt.</summary>
private static void QueryOptional(
SqliteConnection conn, string sql,
Action<SqliteDataReader, Func<SqliteDataReader, string, int>> 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) { }
}
}