478 lines
21 KiB
C#
478 lines
21 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 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<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);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class Rpro3FormatException : Exception
|
|
{
|
|
public Rpro3FormatException(string message, Exception? inner = null) : base(message, inner) { }
|
|
}
|
|
}
|