Compare commits
49 Commits
feature/ca
...
30b2fff775
| Author | SHA1 | Date | |
|---|---|---|---|
| 30b2fff775 | |||
| c0ecf2022d | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| b72e0a9d9a | |||
| 12d85d9796 | |||
| 76bdde4649 | |||
| 025bca1840 | |||
| 58dc811574 | |||
| 367304940f | |||
| 7e1acdb5e4 | |||
| 5ee6a4edf6 | |||
| ea527c7ec6 | |||
| c80adcf060 | |||
| a42966ad8e | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 | |||
| 4403574f3d | |||
| bcdea8011b | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| c452b69dd6 |
@@ -9,10 +9,10 @@
|
|||||||
# 2. npm test + npm run build (Frontend)
|
# 2. npm test + npm run build (Frontend)
|
||||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||||
#
|
#
|
||||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||||
# Images:
|
# Images:
|
||||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: truenas:13000
|
REGISTRY: git.rismer.de
|
||||||
REGISTRY_OWNER: gulum
|
REGISTRY_OWNER: gulum
|
||||||
DOTNET_VERSION: "10.0.x"
|
DOTNET_VERSION: "10.0.x"
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
|
|||||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||||
|
public class CmsPublishTests
|
||||||
|
{
|
||||||
|
private static string TempRoot() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["index.html"] = "<html>start</html>",
|
||||||
|
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||||
|
["assets/site.css"] = "body {}",
|
||||||
|
};
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||||
|
Assert.Equal("<html>start</html>",
|
||||||
|
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||||
|
Assert.Equal("version-2", content);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_mehrfach_ohne_Fehler()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("v3",
|
||||||
|
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(
|
||||||
|
Path.Combine(root, "live", "index.html"),
|
||||||
|
System.Text.Encoding.UTF8);
|
||||||
|
Assert.Equal(german, content);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
|
||||||
|
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
|
||||||
|
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
|
||||||
|
/// </summary>
|
||||||
|
public class ImportDocxServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
|
||||||
|
public ImportDocxServiceTests()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One open connection keeps the :memory: SQLite DB alive across calls.
|
||||||
|
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
|
||||||
|
return (db, conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLitters(object litters) =>
|
||||||
|
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(litters));
|
||||||
|
|
||||||
|
private void WriteAnimals(object animals) =>
|
||||||
|
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(animals));
|
||||||
|
|
||||||
|
// ── Test 1: dry-run shows correct counts without writing ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DryRun_counts_new_animal_without_writing()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
|
||||||
|
var litter = new Litter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Testwurf",
|
||||||
|
Date = new DateOnly(2023, 5, 1),
|
||||||
|
};
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||||
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
|
||||||
|
|
||||||
|
Assert.False(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
|
||||||
|
Assert.Equal(1, report.GoHomeFilled);
|
||||||
|
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
|
||||||
|
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 2: execute creates animal with all fields + FK constraints ───────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
var litter = new Litter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Testwurf",
|
||||||
|
Date = new DateOnly(2023, 5, 1),
|
||||||
|
};
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||||
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.True(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, report.ContactsCreated);
|
||||||
|
|
||||||
|
var gerbil = await db.Gerbils.SingleAsync();
|
||||||
|
Assert.Equal("Pepper", gerbil.Name);
|
||||||
|
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
|
||||||
|
Assert.Equal(Gender.female, gerbil.Gender);
|
||||||
|
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||||
|
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||||
|
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
|
||||||
|
Assert.False(gerbil.IsResident);
|
||||||
|
Assert.Equal("docx", gerbil.ImportSource);
|
||||||
|
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||||
|
|
||||||
|
var contact = await db.Contacts.SingleAsync();
|
||||||
|
Assert.Equal("Max Mustermann", contact.Name);
|
||||||
|
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 3: idempotency — second run creates zero ────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_is_idempotent_second_run_creates_zero()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
||||||
|
owner = "", abgabeDate = "01.05.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, first.Created);
|
||||||
|
|
||||||
|
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_enriches_existing_animal_does_not_duplicate()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
|
||||||
|
var existing = new Gerbil
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Eddie",
|
||||||
|
DateOfBirth = new DateOnly(2022, 8, 10),
|
||||||
|
Gender = Gender.male,
|
||||||
|
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
|
||||||
|
IsResident = false,
|
||||||
|
};
|
||||||
|
db.Gerbils.Add(existing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
// Same name+dob → enrich, not create
|
||||||
|
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
|
||||||
|
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(0, report.Created);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
|
||||||
|
|
||||||
|
// GoHomeDate was enriched
|
||||||
|
var updated = await db.Gerbils.SingleAsync();
|
||||||
|
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 5: Deceased status when deathDate set ───────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
||||||
|
owner = "", abgabeDate = "",
|
||||||
|
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
var gerbil = await db.Gerbils.SingleAsync();
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
|
||||||
|
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
|
||||||
|
Assert.Equal("Tumor", gerbil.CauseOfDeath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_works_under_retrying_execution_strategy()
|
||||||
|
{
|
||||||
|
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||||
|
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||||
|
// InvalidOperationException when it detects a user-initiated transaction
|
||||||
|
// that was NOT opened through the strategy. This test wires the same check
|
||||||
|
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||||
|
// without a live Npgsql instance.
|
||||||
|
//
|
||||||
|
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||||
|
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||||
|
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||||
|
// → OnFirstExecution: no tx yet → OK
|
||||||
|
var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseSqlite(conn)
|
||||||
|
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||||
|
.Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||||
|
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.True(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||||
|
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
|
||||||
|
{
|
||||||
|
// P0 regression: two animals in the same litter with the same normalized name
|
||||||
|
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
|
||||||
|
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
|
||||||
|
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
|
||||||
|
// this test an authoritative regression gate.
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||||
|
owner = "Eva Müller", abgabeDate = "10.08.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" },
|
||||||
|
// Identical name+wsCode+litterDob → base ExternalRef collision
|
||||||
|
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||||
|
owner = "Lena Braun", abgabeDate = "11.08.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must NOT throw unique-key violation
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, report.Created);
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
|
||||||
|
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
|
||||||
|
// Both start with the docx- prefix
|
||||||
|
Assert.All(refs, r => Assert.StartsWith("docx-", r));
|
||||||
|
// Must be distinct (unique index enforces this in SQLite)
|
||||||
|
Assert.Equal(2, refs.Distinct().Count());
|
||||||
|
// Second occurrence gets the -2 suffix
|
||||||
|
Assert.Single(refs, r => r.EndsWith("-2"));
|
||||||
|
|
||||||
|
// Idempotent re-run: zero new, still 2 in DB
|
||||||
|
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Created);
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||||
|
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||||
|
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||||
|
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||||
|
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||||
|
: IExecutionStrategyFactory
|
||||||
|
{
|
||||||
|
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,54 @@ namespace GerbilManager.Tests
|
|||||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
|
||||||
|
{
|
||||||
|
// Regression for "Unable to save changes because a circular dependency was detected":
|
||||||
|
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
|
||||||
|
// Triggered when a gerbil is both parent (in one synth litter) and offspring
|
||||||
|
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
|
||||||
|
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
|
||||||
|
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
|
||||||
|
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
// must NOT throw InvalidOperationException (circular dependency)
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
|
||||||
|
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
|
||||||
|
// both should be litter-linked
|
||||||
|
Assert.NotNull(alpha.LitterId);
|
||||||
|
Assert.NotNull(beta.LitterId);
|
||||||
|
// deferred FK update must have set the litter parents correctly
|
||||||
|
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
|
||||||
|
Assert.Equal(beta.Id, alphaLitter.MotherId);
|
||||||
|
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
|
||||||
|
Assert.Equal(alpha.Id, betaLitter.MotherId);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||||
{
|
{
|
||||||
@@ -573,27 +621,32 @@ namespace GerbilManager.Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||||
{
|
{
|
||||||
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
|
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
|
||||||
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
|
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
|
||||||
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
|
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
|
||||||
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
|
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
|
||||||
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
|
|
||||||
|
|
||||||
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
|
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
|
||||||
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||||
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||||
Assert.NotNull(cpFuchs);
|
Assert.NotNull(cpFuchs);
|
||||||
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||||
|
|
||||||
// New entries at IDs 62-66 exist with correct names
|
// Existing CP-*-Hell entries (IDs 62-66) unchanged
|
||||||
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
||||||
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
||||||
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
||||||
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
||||||
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
||||||
|
|
||||||
// Total count is exactly 66
|
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
|
||||||
Assert.Equal(66, db.ColorVarieties.Count());
|
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
|
||||||
|
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
|
||||||
|
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
|
||||||
|
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
|
||||||
|
|
||||||
|
// Total count is exactly 70
|
||||||
|
Assert.Equal(70, db.ColorVarieties.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
|
|||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||||
/// fence strip), 503-not-configured path, upstream-error path.
|
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NameSuggestionTests
|
public class NameSuggestionTests
|
||||||
{
|
{
|
||||||
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("origin", prompt);
|
Assert.Contains("origin", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||||
|
Assert.Contains("thematischen Kategorien", prompt);
|
||||||
|
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||||
{
|
{
|
||||||
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("6", prompt);
|
Assert.Contains("6", prompt);
|
||||||
Assert.Contains("\"A\"", prompt);
|
Assert.Contains("\"A\"", prompt);
|
||||||
Assert.Contains("weibliche", prompt);
|
Assert.Contains("weibliche", prompt);
|
||||||
Assert.Contains("norn,mythg", prompt);
|
// codes are mapped to German descriptions
|
||||||
|
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||||
|
Assert.Contains("Griechische Mythologie", prompt);
|
||||||
|
Assert.Contains("Kulturkreisen", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
|
|||||||
Assert.DoesNotContain("männliche", prompt);
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("disney", "Disney-Charaktere")]
|
||||||
|
[InlineData("pokemon", "Pokémon-Namen")]
|
||||||
|
[InlineData("encities", "Namen englischer Städte")]
|
||||||
|
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||||
|
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||||
|
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||||
|
{
|
||||||
|
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||||
|
Assert.Equal(expectedDescription, entry.Description);
|
||||||
|
Assert.True(entry.Thematic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("disney", "Disney-Charaktere")]
|
||||||
|
[InlineData("pokemon", "Pokémon-Namen")]
|
||||||
|
[InlineData("encities", "Namen englischer Städte")]
|
||||||
|
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||||
|
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||||
|
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||||
|
Assert.Contains(expectedDescription, prompt);
|
||||||
|
Assert.Contains("Themen-Kategorien", prompt);
|
||||||
|
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||||
|
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||||
|
Assert.Contains("Kulturkreisen", prompt);
|
||||||
|
Assert.Contains("Disney-Charaktere", prompt);
|
||||||
|
Assert.Contains("Themen-Kategorien", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||||
|
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||||
|
{
|
||||||
|
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||||
|
var handler = new StubHandler(_ => Canned(payload));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.NotNull(result.Suggestions);
|
||||||
|
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||||
|
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||||
|
}
|
||||||
|
|
||||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -282,79 +282,97 @@ public class ApplicationContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
|
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
|
||||||
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
|
// ID = array_index + 1 (stable, never changes — live FKs safe).
|
||||||
// SortOrder for new entries is appended; Kevin's frontend catalog handles
|
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
|
||||||
// the interleaved display order via its own sortOrder values.
|
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
|
||||||
(string Name, string Genotype)[] catalog =
|
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
|
||||||
|
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
|
||||||
|
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||||
{
|
{
|
||||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
("REW", "AA chch DD EE GG pp spsp rere", 0),
|
||||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
// --- Zobel / colourpoint dark (ID 4) ---
|
||||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
|
||||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
// --- Schimmel family (IDs 5) ---
|
||||||
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
|
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
|
||||||
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
|
// --- Standard colours (IDs 6-15) ---
|
||||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
|
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
|
||||||
("Blau", "aa CC dd EE GG PP spsp rere"),
|
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
|
||||||
("Gold", "AA CC DD EE GG pp spsp rere"),
|
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
|
||||||
("Platin", "aa CC DD EE GG pp spsp rere"),
|
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
|
||||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
|
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
|
||||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
|
("Blau", "aa CC dd EE GG PP spsp rere", 10),
|
||||||
("dd Gold", "AA CC dd EE GG pp spsp rere"),
|
("Gold", "AA CC DD EE GG pp spsp rere", 11),
|
||||||
("dd Platin", "aa CC dd EE GG pp spsp rere"),
|
("Platin", "aa CC DD EE GG pp spsp rere", 12),
|
||||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
|
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
|
||||||
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
|
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
|
||||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
|
// --- Dilute (dd) standard (IDs 16-17) ---
|
||||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
|
||||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
|
||||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
|
||||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
|
||||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
|
||||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
|
||||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
|
||||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
||||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
// --- Hell variants (IDs 38-48) ---
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||||
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||||
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
|
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||||
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
|
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
|
||||||
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
|
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
|
||||||
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
|
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
|
||||||
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
|
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
|
||||||
|
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
|
||||||
|
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
|
||||||
|
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
|
||||||
|
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
|
||||||
|
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
|
||||||
|
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
|
||||||
|
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
|
||||||
|
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
|
||||||
|
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
|
||||||
|
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
|
||||||
|
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
|
||||||
|
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
|
||||||
|
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
|
||||||
|
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
|
||||||
|
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
|
||||||
|
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
|
||||||
};
|
};
|
||||||
|
|
||||||
var rows = new ColorVariety[catalog.Length];
|
var rows = new ColorVariety[catalog.Length];
|
||||||
@@ -362,11 +380,11 @@ public class ApplicationContext : DbContext
|
|||||||
{
|
{
|
||||||
rows[i] = new ColorVariety
|
rows[i] = new ColorVariety
|
||||||
{
|
{
|
||||||
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
|
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
|
||||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||||
Name = catalog[i].Name,
|
Name = catalog[i].Name,
|
||||||
CanonicalGenotype = catalog[i].Genotype,
|
CanonicalGenotype = catalog[i].Genotype,
|
||||||
SortOrder = i,
|
SortOrder = catalog[i].SortOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||||
|
|||||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Cms
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||||
|
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||||
|
/// In production this volume is shared with the publicsite-nginx container.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PublicSiteOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PublicSite";
|
||||||
|
|
||||||
|
public string? RootPath { get; set; }
|
||||||
|
|
||||||
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using GerbilManagerWebAPI.Cms;
|
using GerbilManagerWebAPI.Cms;
|
||||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
{
|
{
|
||||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||||
|
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||||
|
{
|
||||||
|
if (!opts.Value.IsConfigured)
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||||
|
statusCode: 503,
|
||||||
|
title: "PublicSite nicht konfiguriert");
|
||||||
|
|
||||||
|
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||||
|
var files = SiteRenderer.Render(snapshot);
|
||||||
|
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||||
|
return Results.Ok(new { filesPublished = files.Count });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||||
|
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task PublishToDirectoryAsync(
|
||||||
|
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||||
|
{
|
||||||
|
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||||
|
var liveDir = Path.Combine(rootPath, "live");
|
||||||
|
var oldDir = Path.Combine(rootPath, "_old");
|
||||||
|
|
||||||
|
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||||
|
Directory.CreateDirectory(stagingDir);
|
||||||
|
|
||||||
|
foreach (var (relativePath, content) in files)
|
||||||
|
{
|
||||||
|
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||||
|
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap: _staging_new → live
|
||||||
|
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||||
|
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||||
|
Directory.Move(stagingDir, liveDir);
|
||||||
|
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||||
|
catch { /* non-fatal — old dir gone on next publish */ }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||||
private static string PreviewContentType(string path) =>
|
private static string PreviewContentType(string path) =>
|
||||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||||
|
|||||||
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
public static class ImportDocxEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/import/docx").WithTags("Import");
|
||||||
|
|
||||||
|
// POST /import/docx/dry-run — analyse without writing
|
||||||
|
group.MapPost("/dry-run",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
|
||||||
|
|
||||||
|
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
|
||||||
|
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
|
||||||
|
group.MapPost("/execute",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
424
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
424
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
||||||
|
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
|
||||||
|
///
|
||||||
|
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||||
|
/// Match order per docx row:
|
||||||
|
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
|
||||||
|
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
|
||||||
|
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
|
||||||
|
///
|
||||||
|
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||||
|
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||||
|
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
|
||||||
|
/// -N ordinal suffix disambiguates same-name siblings in a litter).
|
||||||
|
///
|
||||||
|
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||||
|
///
|
||||||
|
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||||
|
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
|
||||||
|
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||||
|
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||||
|
/// to re-run on transient-failure retry.
|
||||||
|
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImportDocxService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
private readonly ApplicationContext _db;
|
||||||
|
private readonly string _sourceDir;
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||||
|
: this(db,
|
||||||
|
config["Import:SourcePath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, string sourceDir)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_sourceDir = sourceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImportDocxReport> RunAsync(bool execute)
|
||||||
|
{
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||||
|
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||||
|
|
||||||
|
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||||
|
{
|
||||||
|
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
||||||
|
"extract_docx.py zuerst ausführen.");
|
||||||
|
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
|
||||||
|
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
|
||||||
|
// Date-only lookup with uniqueness guard avoids false links (only link when
|
||||||
|
// exactly one DB litter falls within ±5 days of the docx litter DOB).
|
||||||
|
var littersInDb = await _db.Litters
|
||||||
|
.Select(l => new { l.Id, l.Date })
|
||||||
|
.ToListAsync();
|
||||||
|
var littersByDayNumber = littersInDb
|
||||||
|
.GroupBy(l => l.Date.DayNumber)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||||
|
var gerbilsInDb = await _db.Gerbils
|
||||||
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
||||||
|
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
|
||||||
|
g.ExternalRef })
|
||||||
|
.ToListAsync();
|
||||||
|
var gerbilByKey = gerbilsInDb
|
||||||
|
.Where(g => g.DateOfBirth is not null)
|
||||||
|
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
|
||||||
|
var docxExternalRefs = gerbilsInDb
|
||||||
|
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
|
||||||
|
.ToDictionary(g => g.ExternalRef!,
|
||||||
|
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
|
||||||
|
|
||||||
|
// Contact lookup: normalized name → existing Contact.Id
|
||||||
|
var contactsInDb = await _db.Contacts
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
var contactByNorm = contactsInDb
|
||||||
|
.GroupBy(c => NormalizeName(c.Name))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
// Snapshot of DB contacts before any writes.
|
||||||
|
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||||
|
// but would remain in the in-memory dict without this reset).
|
||||||
|
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||||
|
|
||||||
|
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||||
|
var colorVarietyByName = (await _db.ColorVarieties
|
||||||
|
.Select(cv => new { cv.Id, cv.Name })
|
||||||
|
.ToListAsync())
|
||||||
|
.GroupBy(cv => NormalizeName(cv.Name))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
||||||
|
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||||
|
|
||||||
|
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||||
|
var externalRefOrdinals = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||||
|
var batchRefs = new HashSet<string>();
|
||||||
|
|
||||||
|
// Inner loop — shared by dry-run and execute paths.
|
||||||
|
// All local variables above are captured by reference (C# closure), so the strategy
|
||||||
|
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||||
|
async Task RunLoopAsync()
|
||||||
|
{
|
||||||
|
foreach (var da in docxAnimals)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Collision-free ExternalRef: ordinal suffix for same-name siblings
|
||||||
|
var baseRef = DocxExternalRefBase(da);
|
||||||
|
externalRefOrdinals.TryGetValue(baseRef, out var ord);
|
||||||
|
ord++;
|
||||||
|
externalRefOrdinals[baseRef] = ord;
|
||||||
|
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
|
||||||
|
|
||||||
|
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
|
||||||
|
Guid? litterId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.LitterDob))
|
||||||
|
{
|
||||||
|
var litterDob = ParseDate(da.LitterDob);
|
||||||
|
if (litterDob is not null)
|
||||||
|
{
|
||||||
|
var candidates = new List<Guid>();
|
||||||
|
for (int delta = -5; delta <= 5; delta++)
|
||||||
|
{
|
||||||
|
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
|
||||||
|
candidates.AddRange(cl.Select(l => l.Id));
|
||||||
|
}
|
||||||
|
if (candidates.Count == 1)
|
||||||
|
litterId = candidates[0];
|
||||||
|
// If 0 or >1 candidates: no link (avoid false links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animal DOB = litter birth date (docx has no per-animal DOB)
|
||||||
|
var animalDob = litterId is not null
|
||||||
|
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
||||||
|
: ParseDate(da.LitterDob);
|
||||||
|
|
||||||
|
if (animalDob is null) { skipped++; continue; }
|
||||||
|
|
||||||
|
var goHomeDate = ParseDate(da.AbgabeDate);
|
||||||
|
var deathDate = ParseDate(da.DeathDate);
|
||||||
|
|
||||||
|
// Resolve receiver contact (lookup-or-create; shared by all paths)
|
||||||
|
Guid? receiverId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.Owner))
|
||||||
|
{
|
||||||
|
var normOwner = NormalizeName(da.Owner);
|
||||||
|
if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
|
||||||
|
{
|
||||||
|
receiverId = existingContactId;
|
||||||
|
ownerLinked++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ownerCreated++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||||
|
_db.Contacts.Add(newContact);
|
||||||
|
await _db.SaveChangesAsync(); // flush within the outer tx
|
||||||
|
receiverId = newContact.Id;
|
||||||
|
contactByNorm[normOwner] = receiverId.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
|
||||||
|
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
|
||||||
|
{
|
||||||
|
bool willLink = litterId is not null && prevSnap.LitterId is null;
|
||||||
|
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
|
||||||
|
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
|
||||||
|
|
||||||
|
if (willLink) litterLinked++;
|
||||||
|
if (willHome) goHomeFilled++;
|
||||||
|
if (willDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
|
||||||
|
var nameKey = NameDobKey(da.Name, animalDob.Value);
|
||||||
|
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
|
||||||
|
{
|
||||||
|
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
||||||
|
?? gerbilCands.First();
|
||||||
|
|
||||||
|
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
|
||||||
|
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
||||||
|
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
||||||
|
|
||||||
|
if (willLink) litterLinked++;
|
||||||
|
if (willHome) goHomeFilled++;
|
||||||
|
if (willDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
||||||
|
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
|
||||||
|
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
|
||||||
|
|
||||||
|
animalsCreated++;
|
||||||
|
if (litterId is not null) litterLinked++;
|
||||||
|
if (goHomeDate is not null) goHomeFilled++;
|
||||||
|
if (deathDate is not null) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
|
||||||
|
|
||||||
|
_db.Gerbils.Add(new Gerbil
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = da.Name.Trim(),
|
||||||
|
DateOfBirth = animalDob,
|
||||||
|
Gender = ParseGender(da.Gender),
|
||||||
|
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
|
||||||
|
LitterId = litterId,
|
||||||
|
ReceiverContactId = receiverId,
|
||||||
|
GoHomeDate = goHomeDate,
|
||||||
|
DateOfDeath = deathDate,
|
||||||
|
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||||
|
ColorVarietyId = cvId == default ? null : cvId,
|
||||||
|
OriginBreeder = "Zucht der Kleinen Chaoten",
|
||||||
|
IsResident = false,
|
||||||
|
ImportSource = "docx",
|
||||||
|
ExternalRef = externalRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!execute)
|
||||||
|
{
|
||||||
|
// Dry-run: just count, no writes, no transaction needed.
|
||||||
|
await RunLoopAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||||
|
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||||
|
// The lambda resets all mutable state at the top so retries start clean.
|
||||||
|
var strategy = _db.Database.CreateExecutionStrategy();
|
||||||
|
await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Reset mutable state — idempotent on strategy retry
|
||||||
|
_db.ChangeTracker.Clear();
|
||||||
|
externalRefOrdinals.Clear();
|
||||||
|
batchRefs.Clear();
|
||||||
|
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||||
|
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||||
|
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||||
|
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
await RunLoopAsync();
|
||||||
|
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||||
|
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||||
|
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||||
|
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||||
|
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||||
|
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||||
|
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
|
||||||
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
|
||||||
|
|
||||||
|
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
|
||||||
|
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Load<T>(string file)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_sourceDir, file);
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDate(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(s,
|
||||||
|
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||||
|
if (!m.Success) return null;
|
||||||
|
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||||
|
int y = int.Parse(m.Groups[3].Value);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
try { return new DateOnly(y, mo, d); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameDobKey(string name, DateOnly dob)
|
||||||
|
{
|
||||||
|
var n = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
|
return $"{n}|{dob:yyyy-MM-dd}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeName(string name)
|
||||||
|
{
|
||||||
|
var n = (name ?? "").ToLowerInvariant();
|
||||||
|
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
|
||||||
|
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
|
||||||
|
/// </summary>
|
||||||
|
internal static string DocxExternalRefBase(DocxAnimal da)
|
||||||
|
{
|
||||||
|
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
|
||||||
|
var name = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
|
return $"docx-{ws}-{name}-{da.LitterDob}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gender ParseGender(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
|
||||||
|
var lower = s.ToLowerInvariant();
|
||||||
|
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
|
||||||
|
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
|
||||||
|
return Gender.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Source shapes (from extract_docx.py output) ----
|
||||||
|
|
||||||
|
public sealed class DocxLitter
|
||||||
|
{
|
||||||
|
public string LitterId { get; set; } = "";
|
||||||
|
public string Dob { get; set; } = "";
|
||||||
|
public string MotherName { get; set; } = "";
|
||||||
|
public string FatherName { get; set; } = "";
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DocxAnimal
|
||||||
|
{
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string LitterDob { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Farbschlag { get; set; } = "";
|
||||||
|
public string Gender { get; set; } = "";
|
||||||
|
public string Owner { get; set; } = "";
|
||||||
|
public string AbgabeDate { get; set; } = "";
|
||||||
|
public string AbgabeWeight { get; set; } = "";
|
||||||
|
public string DeathDate { get; set; } = "";
|
||||||
|
public string DeathCause { get; set; } = "";
|
||||||
|
public string PartnerName { get; set; } = "";
|
||||||
|
public string PartnerDob { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report ----
|
||||||
|
|
||||||
|
public sealed record ImportDocxReport(
|
||||||
|
bool Executed,
|
||||||
|
int Created,
|
||||||
|
int LitterLinked,
|
||||||
|
int OwnerLinked,
|
||||||
|
int GoHomeFilled,
|
||||||
|
int DeathFilled,
|
||||||
|
int ContactsCreated,
|
||||||
|
int Skipped,
|
||||||
|
IReadOnlyList<string> Notes);
|
||||||
|
}
|
||||||
@@ -289,10 +289,15 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
litterParents[lid] = (f, m);
|
litterParents[lid] = (f, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
|
||||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
|
||||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
|
||||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
|
||||||
|
|
||||||
|
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
|
||||||
|
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
|
||||||
|
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
|
||||||
|
// which would cause EF's topo-sort to throw "circular dependency detected".
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||||
@@ -316,13 +321,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
derivedLitters--;
|
derivedLitters--;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
|
||||||
|
// in this same batch. Setting them now causes EF circular dependency
|
||||||
|
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
|
||||||
|
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = sl.Id,
|
Id = sl.Id,
|
||||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||||
Date = sl.Date ?? default,
|
Date = sl.Date ?? default,
|
||||||
FatherId = sl.Father,
|
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
MotherId = sl.Mother,
|
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -437,6 +446,20 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
|
||||||
|
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
|
||||||
|
if (execute && synthLitterPendingParents.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (litId, (f, m)) in synthLitterPendingParents)
|
||||||
|
{
|
||||||
|
var row = await _db.Litters.FindAsync(litId);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (f is not null) row.FatherId = f;
|
||||||
|
if (m is not null) row.MotherId = m;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
|
|||||||
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReseedColorVarietiesAR5 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Gold");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Platin");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Agouti");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Silberagouti");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Kohlfuchs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Anthrazit");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 36);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 37);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 38);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 39);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 40);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 41);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 43);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 44);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 45);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 46);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 47);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 48);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 49);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 50);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 51);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Dilute Topas", 52 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Dilute Blaufuchs", 53 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 54);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 55);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 56);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 57);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 59);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 61);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 63);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 65);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 66);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 67);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 68);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 58);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 60);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 62);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 69);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
|
column: "Name",
|
||||||
|
value: "dd Gold");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
|
column: "Name",
|
||||||
|
value: "dd Platin");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Agouti dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Silberagouti dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Kohlfuchs dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Anthrazit dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 32);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 33);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 34);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 35);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 36);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 37);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 38);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 39);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 40);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 41);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 43);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 44);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 45);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 46);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 47);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Topas dd", 48 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Blaufuchs dd", 49 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 50);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 51);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 52);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 53);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 54);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 55);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 56);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 57);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 58);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 59);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 60);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 61);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 62);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 63);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RenameREW : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||||
|
column: "Name",
|
||||||
|
value: "REW");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Pink Eyed White (PEW)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,7 +216,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||||
Name = "Pink Eyed White (PEW)",
|
Name = "REW",
|
||||||
SortOrder = 0
|
SortOrder = 0
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -321,14 +321,14 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "dd Gold",
|
Name = "Dilute Gold",
|
||||||
SortOrder = 15
|
SortOrder = 15
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||||
Name = "dd Platin",
|
Name = "Dilute Platin",
|
||||||
SortOrder = 16
|
SortOrder = 16
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -412,28 +412,28 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||||
Name = "Agouti dd",
|
Name = "Dilute Agouti",
|
||||||
SortOrder = 28
|
SortOrder = 28
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||||
Name = "Silberagouti dd",
|
Name = "Dilute Silberagouti",
|
||||||
SortOrder = 29
|
SortOrder = 29
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs dd",
|
Name = "Dilute Kohlfuchs",
|
||||||
SortOrder = 30
|
SortOrder = 30
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||||
Name = "Anthrazit dd",
|
Name = "Dilute Anthrazit",
|
||||||
SortOrder = 31
|
SortOrder = 31
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -441,224 +441,224 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Silberschimmel",
|
Name = "Silberschimmel",
|
||||||
SortOrder = 32
|
SortOrder = 36
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Polarfuchsschimmel",
|
Name = "Polarfuchsschimmel",
|
||||||
SortOrder = 33
|
SortOrder = 37
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||||
Name = "Algierfuchsschimmel",
|
Name = "Algierfuchsschimmel",
|
||||||
SortOrder = 34
|
SortOrder = 38
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel",
|
Name = "Kohlfuchsschimmel",
|
||||||
SortOrder = 35
|
SortOrder = 39
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||||
Name = "Blaufuchsschimmel",
|
Name = "Blaufuchsschimmel",
|
||||||
SortOrder = 36
|
SortOrder = 40
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs, hell",
|
Name = "Kohlfuchs, hell",
|
||||||
SortOrder = 37
|
SortOrder = 41
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||||
Name = "Goldfuchs, hell",
|
Name = "Goldfuchs, hell",
|
||||||
SortOrder = 38
|
SortOrder = 42
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||||
Name = "Goldfuchsschimmel",
|
Name = "Goldfuchsschimmel",
|
||||||
SortOrder = 39
|
SortOrder = 43
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||||
Name = "Gold-Hell",
|
Name = "Gold-Hell",
|
||||||
SortOrder = 40
|
SortOrder = 44
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||||
Name = "Blaufuchs, hell",
|
Name = "Blaufuchs, hell",
|
||||||
SortOrder = 41
|
SortOrder = 45
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||||
Name = "Rotfuchsschimmel",
|
Name = "Rotfuchsschimmel",
|
||||||
SortOrder = 42
|
SortOrder = 46
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||||
Name = "Polarfuchs, hell",
|
Name = "Polarfuchs, hell",
|
||||||
SortOrder = 43
|
SortOrder = 47
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel, hell",
|
Name = "Kohlfuchsschimmel, hell",
|
||||||
SortOrder = 44
|
SortOrder = 48
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||||
Name = "Rotfuchs, hell",
|
Name = "Rotfuchs, hell",
|
||||||
SortOrder = 45
|
SortOrder = 49
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs-Hell",
|
Name = "Kohlfuchs-Hell",
|
||||||
SortOrder = 46
|
SortOrder = 50
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||||
Name = "Algierfuchs, hell",
|
Name = "Algierfuchs, hell",
|
||||||
SortOrder = 47
|
SortOrder = 51
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "Topas dd",
|
Name = "Dilute Topas",
|
||||||
SortOrder = 48
|
SortOrder = 52
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||||
Name = "Blaufuchs dd",
|
Name = "Dilute Blaufuchs",
|
||||||
SortOrder = 49
|
SortOrder = 53
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "Marder",
|
Name = "Marder",
|
||||||
SortOrder = 50
|
SortOrder = 54
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
||||||
Name = "Siam",
|
Name = "Siam",
|
||||||
SortOrder = 51
|
SortOrder = 55
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
||||||
Name = "Zobel-Hell",
|
Name = "Zobel-Hell",
|
||||||
SortOrder = 52
|
SortOrder = 56
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "CP-Agouti",
|
Name = "CP-Agouti",
|
||||||
SortOrder = 53
|
SortOrder = 57
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
Name = "CP-Silberagouti",
|
Name = "CP-Silberagouti",
|
||||||
SortOrder = 54
|
SortOrder = 59
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
Name = "CP-Algierfuchs",
|
Name = "CP-Algierfuchs",
|
||||||
SortOrder = 55
|
SortOrder = 61
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
Name = "CP-Polarfuchs",
|
Name = "CP-Polarfuchs",
|
||||||
SortOrder = 56
|
SortOrder = 63
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
Name = "CP-Fuchs",
|
Name = "CP-Fuchs",
|
||||||
SortOrder = 57
|
SortOrder = 65
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
||||||
Name = "CP-Fuchs-Hell",
|
Name = "CP-Fuchs-Hell",
|
||||||
SortOrder = 58
|
SortOrder = 66
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
Name = "CP-Blaufuchs",
|
Name = "CP-Blaufuchs",
|
||||||
SortOrder = 59
|
SortOrder = 67
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel",
|
Name = "CP-Orangeschimmel",
|
||||||
SortOrder = 60
|
SortOrder = 68
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
||||||
Name = "CP-Agouti-Hell",
|
Name = "CP-Agouti-Hell",
|
||||||
SortOrder = 61
|
SortOrder = 58
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
||||||
Name = "CP-Silberagouti-Hell",
|
Name = "CP-Silberagouti-Hell",
|
||||||
SortOrder = 62
|
SortOrder = 60
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
||||||
Name = "CP-Algierfuchs-Hell",
|
Name = "CP-Algierfuchs-Hell",
|
||||||
SortOrder = 63
|
SortOrder = 62
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
@@ -672,7 +672,35 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel-Hell",
|
Name = "CP-Orangeschimmel-Hell",
|
||||||
SortOrder = 65
|
SortOrder = 69
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee GG PP spsp rere",
|
||||||
|
Name = "Dilute Algierfuchs",
|
||||||
|
SortOrder = 32
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee GG pp spsp rere",
|
||||||
|
Name = "Dilute Goldfuchs",
|
||||||
|
SortOrder = 33
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||||
|
CanonicalGenotype = "aa CC dd ee GG pp spsp rere",
|
||||||
|
Name = "Dilute Rotfuchs",
|
||||||
|
SortOrder = 34
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee gg PP spsp rere",
|
||||||
|
Name = "Dilute Polarfuchs",
|
||||||
|
SortOrder = 35
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Known usage codes → (German description, isThematic).
|
||||||
|
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
|
||||||
|
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["norn"] = ("Nordische/Altnordische Etymologie", false),
|
||||||
|
["japa"] = ("Japanische Etymologie", false),
|
||||||
|
["mythg"] = ("Griechische Mythologie", false),
|
||||||
|
["ger"] = ("Germanische/Deutsche Etymologie", false),
|
||||||
|
["arb"] = ("Arabische Etymologie", false),
|
||||||
|
["disney"] = ("Disney-Charaktere", true),
|
||||||
|
["pokemon"] = ("Pokémon-Namen", true),
|
||||||
|
["encities"] = ("Namen englischer Städte", true),
|
||||||
|
["hrcities"] = ("Namen kroatischer Städte", true),
|
||||||
|
["usstates"] = ("Namen von US-Bundesstaaten", true),
|
||||||
|
};
|
||||||
|
|
||||||
public async Task<NameSuggestionResult> SuggestAsync(
|
public async Task<NameSuggestionResult> SuggestAsync(
|
||||||
string? letter, string? gender, string? usages, int count,
|
string? letter, string? gender, string? usages, int count,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
|
||||||
|
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
|
||||||
|
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
|
||||||
|
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
|
||||||
|
"Erfinde KEINE Etymologie für thematische Kategorien.";
|
||||||
|
|
||||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||||
{
|
{
|
||||||
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||||
|
|
||||||
|
bool hasThematic = false;
|
||||||
if (!string.IsNullOrWhiteSpace(usages))
|
if (!string.IsNullOrWhiteSpace(usages))
|
||||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
{
|
||||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
var etymDescriptions = new List<string>();
|
||||||
|
var thematicDescriptions = new List<string>();
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
if (UsageMap.TryGetValue(code, out var entry))
|
||||||
|
{
|
||||||
|
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
|
||||||
|
else etymDescriptions.Add(entry.Description);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
etymDescriptions.Add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (etymDescriptions.Count > 0)
|
||||||
|
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
|
||||||
|
if (thematicDescriptions.Count > 0)
|
||||||
|
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
|
||||||
|
hasThematic = thematicDescriptions.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(". ");
|
||||||
|
if (hasThematic)
|
||||||
|
{
|
||||||
|
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
|
||||||
|
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
|
||||||
|
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||||
|
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||||
|
}
|
||||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
|||||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
|
||||||
|
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
|
||||||
|
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||||
@@ -98,6 +101,7 @@ app.MapInbreedingEndpoints();
|
|||||||
app.MapPhotoEndpoints();
|
app.MapPhotoEndpoints();
|
||||||
app.MapSaleAdEndpoints();
|
app.MapSaleAdEndpoints();
|
||||||
app.MapImportEndpoints();
|
app.MapImportEndpoints();
|
||||||
|
app.MapImportDocxEndpoints();
|
||||||
app.MapContractEndpoints();
|
app.MapContractEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
|
|||||||
209
HUMANQUESTION.md
209
HUMANQUESTION.md
@@ -2,150 +2,78 @@
|
|||||||
|
|
||||||
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
||||||
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
||||||
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
|
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
|
||||||
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
|
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
|
||||||
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
|
> das, was **noch offen** ist. Nichts geht verloren.
|
||||||
> alles wartet geduldig, bis ihr antwortet.
|
|
||||||
|
|
||||||
_Stand: 2026-06-06._
|
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
|
||||||
|
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
|
||||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
|
||||||
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
|
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
|
||||||
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
|
Feinschliff** — siehe unten._
|
||||||
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Für Julian — Konten, Schlüssel, Technik
|
## A. Zugänge & Technik (für Julian)
|
||||||
|
|
||||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
| # | Was gebraucht wird | Schaltet frei |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| ~~A1~~ | ✅ **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
||||||
| ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
|
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
|
||||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
|
||||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
|
||||||
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
|
||||||
|
|
||||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
## B. Kleine Aktion (jederzeit)
|
||||||
|
|
||||||
| # | Aktion | Warum |
|
| # | Aktion | Warum |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
|
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
|
||||||
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
|
|
||||||
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
|
## C. Genetik-Feinschliff (optional, blockiert nichts)
|
||||||
|
|
||||||
|
| # | Frage | Betrifft |
|
||||||
|
|---|---|---|
|
||||||
|
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
|
||||||
|
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
|
||||||
|
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||||
|
|
||||||
|
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
|
||||||
|
|
||||||
|
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
|
||||||
|
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
|
||||||
|
|
||||||
|
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
|
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
||||||
|
|
||||||
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
|
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||||
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|
||||||
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
|
|
||||||
|
|
||||||
| # | Frage | Blockiert |
|
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
|
||||||
|
|
||||||
|
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||||
|
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
|
||||||
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
|
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
|
||||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
|
||||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
|
||||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||||
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
|
||||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
|
||||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
|
||||||
|
|
||||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||||
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
|
|
||||||
|
|
||||||
> ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
|
|
||||||
|
|
||||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
|
||||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
|
||||||
|
|
||||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
|
||||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
|
||||||
|
|
||||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
|
||||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
|
||||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
|
||||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
|
||||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
|
||||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
|
||||||
|
|
||||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
|
||||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
|
||||||
|
|
||||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
|
||||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
|
||||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
|
||||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
|
||||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
|
||||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
|
||||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
|
||||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
|
||||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
|
||||||
|
|
||||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
|
||||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
|
||||||
>
|
|
||||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
|
||||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
|
|
||||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
|
||||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
|
||||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
|
||||||
|
|
||||||
### D5 · **Sterbedatum** widersprüchlich
|
|
||||||
| Tier | Problem |
|
|
||||||
|---|---|
|
|
||||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
|
||||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
|
||||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
|
||||||
|
|
||||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
|
||||||
|
|
||||||
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? |
|
|
||||||
|---|---|
|
|
||||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
|
||||||
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
|
|
||||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
|
|
||||||
|
|
||||||
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
|
|
||||||
sagen, was ergänzt oder gestrichen werden soll:**
|
|
||||||
|
|
||||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
|
||||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
|
||||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
|
||||||
|
|
||||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
|
|
||||||
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,16 +81,25 @@ sagen, was ergänzt oder gestrichen werden soll:**
|
|||||||
|
|
||||||
| Funktion | Status | Wartet auf |
|
| Funktion | Status | Wartet auf |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
|
||||||
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
|
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
|
||||||
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
|
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
|
||||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
|
||||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — |
|
||||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
|
||||||
| Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
|
|
||||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
|
## ✅ Erledigt (Archiv — nichts geht verloren)
|
||||||
sie hier und meldet, was dadurch live geht.*
|
|
||||||
|
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
|
||||||
|
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
|
||||||
|
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
|
||||||
|
- **C1–C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
|
||||||
|
- **D1–D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
|
||||||
|
|
||||||
|
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ KEYS_PATH=/mnt/SSD/gerbil/keys
|
|||||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||||
BACKUP_KEEP_DAYS=7
|
BACKUP_KEEP_DAYS=7
|
||||||
|
|
||||||
|
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
|
||||||
|
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
|
||||||
|
PUBLICSITE_PORT=8081
|
||||||
|
|
||||||
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||||
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ services:
|
|||||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||||
AI__ApiKey: "${AI__ApiKey:-}"
|
AI__ApiKey: "${AI__ApiKey:-}"
|
||||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||||
|
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
|
||||||
|
PublicSite__RootPath: /data/publicsite
|
||||||
volumes:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
- keys:/data/keys
|
- keys:/data/keys
|
||||||
|
- publicsite:/data/publicsite
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,6 +77,21 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
# --- nginx Public Site (WEB-2) ---
|
||||||
|
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
|
||||||
|
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
|
||||||
|
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
|
||||||
|
publicsite:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PUBLICSITE_PORT:-8081}:80"
|
||||||
|
volumes:
|
||||||
|
- publicsite:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||||
backup:
|
backup:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -122,3 +140,10 @@ volumes:
|
|||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||||
|
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
|
||||||
|
publicsite:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
|
||||||
|
|||||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# GerbilManager — publicsite nginx (WEB-2)
|
||||||
|
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||||
|
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html/live;
|
||||||
|
index index.html;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/index.html =404;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSS/Bilder: kurze TTL (1 Tag)
|
||||||
|
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, max-age=86400";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kein Zugriff auf Staging-Verzeichnisse
|
||||||
|
location ~ ^/_(staging_new|old)/ {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||||
|
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||||
|
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||||
|
#
|
||||||
|
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||||
|
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||||
|
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name <DOMAIN>;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl;
|
||||||
|
# server_name <DOMAIN>;
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://127.0.0.1:8081;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
|
||||||
|
|
||||||
|
> **Zielgruppe:** Julian.
|
||||||
|
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
|
||||||
|
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
| HTTPS/HTTP
|
||||||
|
v
|
||||||
|
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
|
||||||
|
| proxy_pass http://127.0.0.1:8081
|
||||||
|
v
|
||||||
|
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
|
||||||
|
| (Shared Volume, read-only)
|
||||||
|
| [POST /api/publish im Manager schreibt in dasselbe Volume]
|
||||||
|
v
|
||||||
|
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
|
||||||
|
| (Shared Volume, read-write)
|
||||||
|
v
|
||||||
|
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
|
||||||
|
```
|
||||||
|
|
||||||
|
**SICHERHEIT — harte Bedingung:**
|
||||||
|
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
|
||||||
|
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
|
||||||
|
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erstinstallation
|
||||||
|
|
||||||
|
### 1. Verzeichnis anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
|
||||||
|
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
|
||||||
|
|
||||||
|
### 2. .env erganzen
|
||||||
|
|
||||||
|
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
|
||||||
|
|
||||||
|
```env
|
||||||
|
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
PUBLICSITE_PORT=8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Compose-Stack neu starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/gerbilmanager
|
||||||
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Der neue `publicsite`-Container startet und serviert Port 8081.
|
||||||
|
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
|
||||||
|
|
||||||
|
### 4. Julians externen nginx konfigurieren
|
||||||
|
|
||||||
|
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
|
||||||
|
(als eigenen `server`-Block oder per `include`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
|
||||||
|
nano /etc/nginx/conf.d/gerbilmanager-public.conf
|
||||||
|
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seite veroeffentlichen (Publish-Ablauf)
|
||||||
|
|
||||||
|
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||||
|
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
|
||||||
|
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
|
||||||
|
|
||||||
|
**Was passiert intern:**
|
||||||
|
```
|
||||||
|
POST /api/publish
|
||||||
|
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
|
||||||
|
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
|
||||||
|
→ Schreibt Dateien nach /data/publicsite/_staging_new/
|
||||||
|
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
|
||||||
|
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
|
||||||
|
→ Kein Container-Restart, kein Image-Rebuild, kein CI
|
||||||
|
Response: { "filesPublished": N }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# publicsite-Container laeuft?
|
||||||
|
docker compose -f deploy/truenas/compose.yaml ps publicsite
|
||||||
|
|
||||||
|
# Seite lokal abrufbar?
|
||||||
|
curl -s http://localhost:8081/ | head -5
|
||||||
|
|
||||||
|
# live/-Verzeichnis gefuellt?
|
||||||
|
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
|
||||||
|
|
||||||
|
# Oeffentlich erreichbar (nach DNS-Propagation)?
|
||||||
|
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitstrennung (Pflichtcheck)
|
||||||
|
|
||||||
|
| Was | Port | Internet-exponiert? |
|
||||||
|
|-----|------|---------------------|
|
||||||
|
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
|
||||||
|
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
|
||||||
|
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
|
||||||
|
|
||||||
|
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
|
||||||
|
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
|
||||||
|
Sie sind ausschliesslich im Heimnetz erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hostname noch ausstehend
|
||||||
|
|
||||||
|
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||||
|
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||||
@@ -54,6 +54,23 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
|||||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
|
||||||
|
const nameLink = page.getByRole('link', { name: 'Fridolin' })
|
||||||
|
await expect(nameLink).toBeVisible()
|
||||||
|
// href korrekt gesetzt
|
||||||
|
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
|
||||||
|
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
|
||||||
|
await nameLink.focus()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
|
||||||
|
})
|
||||||
|
|
||||||
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||||
@@ -62,3 +79,61 @@ test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', a
|
|||||||
// Karte selbst zeigt '(ohne Namen)' statt leer
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const hints = page.locator('.stammbaum-hints')
|
||||||
|
await expect(hints).toBeVisible()
|
||||||
|
await expect(hints).toContainText(t.tapHint)
|
||||||
|
await expect(hints).toContainText(t.hintName)
|
||||||
|
await expect(hints).toContainText(t.hintExpand)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Einpassen, damit die 4. Generation (Emil mit +) im Viewport liegt.
|
||||||
|
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||||
|
if (await fit.isVisible()) await fit.click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
|
||||||
|
// +-Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
|
||||||
|
const expandBtn = page.getByRole('button', { name: t.expand }).first()
|
||||||
|
await expect(expandBtn).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf +: Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
|
||||||
|
await expandBtn.click({ force: true })
|
||||||
|
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
|
||||||
|
await page.goto('/rennmaeuse/fridolin/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const panel = page.locator('.stammbaum-litters-panel')
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
await expect(panel).toContainText(t.littersTitle)
|
||||||
|
await expect(panel).toContainText('Wurf K')
|
||||||
|
await expect(panel).toContainText('5')
|
||||||
|
|
||||||
|
// Link-Klick → Wurf-Detailseite
|
||||||
|
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
|
||||||
|
await expect(wurfLink).toBeVisible()
|
||||||
|
await wurfLink.click()
|
||||||
|
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
|
|||||||
expect(codes).toContain('mythg')
|
expect(codes).toContain('mythg')
|
||||||
expect(codes).toContain('ger')
|
expect(codes).toContain('ger')
|
||||||
expect(codes).toContain('arb')
|
expect(codes).toContain('arb')
|
||||||
expect(codes).toHaveLength(5)
|
expect(codes).toContain('disney')
|
||||||
|
expect(codes).toContain('pokemon')
|
||||||
|
expect(codes).toContain('encities')
|
||||||
|
expect(codes).toContain('hrcities')
|
||||||
|
expect(codes).toContain('usstates')
|
||||||
|
expect(codes).toHaveLength(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('every usage has a non-empty label', () => {
|
it('every usage has a non-empty label', () => {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
|
|||||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||||
{ code: 'ger', label: 'Deutsch' },
|
{ code: 'ger', label: 'Deutsch' },
|
||||||
{ code: 'arb', label: 'Arabisch' },
|
{ code: 'arb', label: 'Arabisch' },
|
||||||
|
{ code: 'disney', label: 'Disney' },
|
||||||
|
{ code: 'pokemon', label: 'Pokémon' },
|
||||||
|
{ code: 'encities', label: 'Englische Städte' },
|
||||||
|
{ code: 'hrcities', label: 'Kroatische Städte' },
|
||||||
|
{ code: 'usstates', label: 'US-Bundesstaaten' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ALL_TRAITS } from '../format/traits'
|
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||||
import './charakterbogen.css'
|
import './charakterbogen.css'
|
||||||
|
|
||||||
export interface CharakterbogenProps {
|
export interface CharakterbogenProps {
|
||||||
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
|
|||||||
onNoteChange: (note: string) => void
|
onNoteChange: (note: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
|
||||||
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
|
||||||
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
|
||||||
* de.character.traits; the stored value is the trait KEY.
|
|
||||||
*/
|
|
||||||
export default function Charakterbogen({
|
export default function Charakterbogen({
|
||||||
traits,
|
traits,
|
||||||
note,
|
note,
|
||||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
|||||||
const next = new Set(selected)
|
const next = new Set(selected)
|
||||||
if (next.has(key)) next.delete(key)
|
if (next.has(key)) next.delete(key)
|
||||||
else next.add(key)
|
else next.add(key)
|
||||||
// Preserve the vocabulary order for stable output.
|
// Preserve vocabulary (category) order for stable output.
|
||||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="charakterbogen">
|
<div className="charakterbogen">
|
||||||
<ul className="trait-grid">
|
{TRAIT_CATEGORIES.map((cat) => (
|
||||||
{ALL_TRAITS.map((tr) => (
|
<section key={cat.category} className="trait-category">
|
||||||
<li key={tr.key}>
|
<h4 className="trait-category__heading">{cat.category}</h4>
|
||||||
<label className="trait-chip">
|
<ul className="trait-grid">
|
||||||
<input
|
{cat.traits.map((tr) => (
|
||||||
type="checkbox"
|
<li key={tr.key}>
|
||||||
checked={selected.has(tr.key)}
|
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
|
||||||
onChange={() => toggle(tr.key)}
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
<span>{tr.label}</span>
|
checked={selected.has(tr.key)}
|
||||||
</label>
|
onChange={() => toggle(tr.key)}
|
||||||
</li>
|
/>
|
||||||
))}
|
<span>{tr.label}</span>
|
||||||
</ul>
|
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.noteLabel}</span>
|
<span>{t.noteLabel}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
|
||||||
|
|
||||||
|
.trait-category {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-category__heading {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.charakterbogen .trait-grid {
|
.charakterbogen .trait-grid {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -39,3 +52,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trait-chip--warn {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-warn-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d97706;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
|
||||||
|
|
||||||
|
describe('trait catalog', () => {
|
||||||
|
it('has 4 categories', () => {
|
||||||
|
expect(TRAIT_CATEGORIES).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('category names match spec', () => {
|
||||||
|
const names = TRAIT_CATEGORIES.map((c) => c.category)
|
||||||
|
expect(names).toEqual([
|
||||||
|
'Sozialverhalten',
|
||||||
|
'Eignung & Umgang',
|
||||||
|
'Hobbys & Eigenarten',
|
||||||
|
'Wesen & Temperament',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ALL_TRAITS flattens all categories', () => {
|
||||||
|
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
|
||||||
|
expect(ALL_TRAITS).toHaveLength(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all keys are unique', () => {
|
||||||
|
const keys = ALL_TRAITS.map((t) => t.key)
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('existing 15 keys are still present (stored on live animals)', () => {
|
||||||
|
const legacy = [
|
||||||
|
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
|
||||||
|
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
|
||||||
|
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of legacy) {
|
||||||
|
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('new keys are present', () => {
|
||||||
|
const newKeys = [
|
||||||
|
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
|
||||||
|
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
|
||||||
|
'schredder', 'nestbauer', 'territorial',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of newKeys) {
|
||||||
|
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
|
||||||
|
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
|
||||||
|
expect(isWarnTrait('territorial')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-warn traits return false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('zutraulich')).toBe(false)
|
||||||
|
expect(isWarnTrait('dominant')).toBe(false)
|
||||||
|
expect(isWarnTrait('beobachtungstier')).toBe(false)
|
||||||
|
expect(isWarnTrait('schreckhaft')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown key returns false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('not-a-real-key')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns German label for known key', () => {
|
||||||
|
expect(traitLabel('zutraulich')).toBe('zutraulich')
|
||||||
|
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
|
||||||
|
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
|
||||||
|
expect(traitLabel('territorial')).toBe('territorial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns the key itself for unknown key', () => {
|
||||||
|
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels maps a list of keys to labels', () => {
|
||||||
|
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
|
||||||
|
'zutraulich',
|
||||||
|
'dominant (Leittier)',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels handles null and undefined gracefully', () => {
|
||||||
|
expect(traitLabels(null)).toEqual([])
|
||||||
|
expect(traitLabels(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
export const ALL_TRAITS = de.character.traits
|
export interface TraitEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
warn?: true
|
||||||
|
}
|
||||||
|
|
||||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
export interface TraitCategory {
|
||||||
|
category: string
|
||||||
|
traits: readonly TraitEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
|
||||||
|
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
|
||||||
|
|
||||||
|
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
|
||||||
|
|
||||||
|
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
|
||||||
|
|
||||||
export function traitLabel(key: string): string {
|
export function traitLabel(key: string): string {
|
||||||
return LABEL_BY_KEY.get(key) ?? key
|
return LABEL_BY_KEY.get(key) ?? key
|
||||||
@@ -13,3 +27,7 @@ export function traitLabel(key: string): string {
|
|||||||
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||||
return (keys ?? []).map(traitLabel)
|
return (keys ?? []).map(traitLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWarnTrait(key: string): boolean {
|
||||||
|
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe('Fraction', () => {
|
|||||||
describe('Genotype serialization', () => {
|
describe('Genotype serialization', () => {
|
||||||
it('round-trips display string <-> structured form', () => {
|
it('round-trips display string <-> structured form', () => {
|
||||||
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
|
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
|
||||||
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parses multi-char C-series alleles via maximal munch', () => {
|
it('parses multi-char C-series alleles via maximal munch', () => {
|
||||||
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
|
|||||||
expect(g.P).toEqual(['P', 'p'])
|
expect(g.P).toEqual(['P', 'p'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('wild type is AA CC DD EE GG PP spsp rere', () => {
|
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
|
||||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
|
|||||||
|
|
||||||
expect(result.offspring).toHaveLength(1)
|
expect(result.offspring).toHaveLength(1)
|
||||||
const only = result.offspring[0]
|
const only = result.offspring[0]
|
||||||
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
|
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
|
||||||
expect(only.probability.text).toBe('1')
|
expect(only.probability.text).toBe('1')
|
||||||
expect(result.warnings).toHaveLength(0)
|
expect(result.warnings).toHaveLength(0)
|
||||||
})
|
})
|
||||||
@@ -185,15 +185,11 @@ describe('Farbschlag catalog', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||||
// The first 18 are the frozen ColorVariety keys — their representative
|
// The first 17 frozen ColorVariety keys — their representative genotype
|
||||||
// genotype MUST resolve back to their own name, never a later variety.
|
// MUST resolve back to their own name, never a later variety.
|
||||||
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
|
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
|
||||||
// because the REW engine check (both C-alleles reduced + pp) fires first.
|
// chch+pp → REW engine check → 'REW' = entry.name.
|
||||||
// PEW stays in the catalog as a user-pickable import name; its computed
|
|
||||||
// farbschlag is intentionally 'REW' per Julian's extended rule.
|
|
||||||
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
|
|
||||||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||||||
if (REW_SHADOWED.has(entry.name)) continue
|
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -224,11 +220,12 @@ describe('Farbschlag catalog', () => {
|
|||||||
|
|
||||||
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
|
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
|
||||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
||||||
// Every row has a non-empty canonical genotype display string and unique name.
|
// Every row has a non-empty canonical genotype display string and unique name.
|
||||||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||||
|
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
|
||||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||||
@@ -289,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
|
|||||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||||
'AA CC DD EE Gg PP spsp rere',
|
'AA CC DD EE Gg PP spsp',
|
||||||
)
|
)
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||||
})
|
})
|
||||||
@@ -301,10 +298,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
|||||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
|
||||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
|
||||||
|
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||||
|
// Rex het → Rere shown
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
|
||||||
|
'AA CC DD EE GG PP spsp Rere',
|
||||||
|
)
|
||||||
|
// WP → Slsl shown, rere still omitted
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||||||
'AA CC DD EE GG PP spsp rere Slsl',
|
'AA CC DD EE GG PP spsp Slsl',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -334,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
|||||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||||
@@ -367,11 +370,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
|||||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
|
||||||
})
|
})
|
||||||
it("'?' and '-' inputs are equivalent", () => {
|
it("'?' and '-' inputs are equivalent", () => {
|
||||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||||
'Aa C- DD EE GG Pp spsp rere',
|
'Aa C- DD EE GG Pp spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -396,10 +399,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
|
|||||||
|
|
||||||
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||||
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
||||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
|
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
|
||||||
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
||||||
expect(name).toBe('Schwarz')
|
expect(name).toBe('Schwarz')
|
||||||
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
|
expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
||||||
@@ -549,15 +552,16 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
||||||
// hom cchm/cchm + pp
|
// hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
|
||||||
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
|
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-)
|
||||||
|
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
|
||||||
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
||||||
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
||||||
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
||||||
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
||||||
// het cchm/ch + pp (Julian: also REW)
|
// het cchm/ch + pp (Julian: also REW)
|
||||||
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
// ch/ch + pp (Julian: also REW — subsumes PEW)
|
// ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
|
||||||
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
// Counterproof: full C present → NOT REW (residual pigment)
|
// Counterproof: full C present → NOT REW (residual pigment)
|
||||||
@@ -593,15 +597,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||||
// Fuchsschimmel: E=[ef,ef] hom
|
// Fuchsschimmel: E=[ef,ef] hom
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||||
'AA CC DD e[f]e[f] GG PP spsp rere',
|
'AA CC DD e[f]e[f] GG PP spsp',
|
||||||
)
|
)
|
||||||
// C-locus het: cchm + ch
|
// C-locus het: cchm + ch
|
||||||
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||||
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
'aa c[chm]c[h] DD EE GG PP spsp',
|
||||||
)
|
)
|
||||||
// C-locus hom cchm
|
// C-locus hom cchm
|
||||||
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||||
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
'aa c[chm]c[chm] DD EE GG PP spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -611,24 +615,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
// Display must swap to [e, ef] per breeder convention.
|
// Display must swap to [e, ef] per breeder convention.
|
||||||
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||||
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||||||
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
|
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||||
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||||
'aa CC DD Ee GG PP spsp rere',
|
'aa CC DD Ee GG PP spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||||
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||||
'aa CC DD Ee[f] GG PP spsp rere',
|
'aa CC DD Ee[f] GG PP spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
|
||||||
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
const display = 'aa C- D- ee[f] Gg Pp spsp'
|
||||||
const g = fromDisplayString(display)
|
const g = fromDisplayString(display)
|
||||||
expect(g.E).toEqual(['ef', 'e'])
|
expect(g.E).toEqual(['ef', 'e'])
|
||||||
expect(g.C).toEqual(['C', '?'])
|
expect(g.C).toEqual(['C', '?'])
|
||||||
@@ -636,16 +640,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
|
||||||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
|
||||||
const g = fromDisplayString(display)
|
const g = fromDisplayString(display)
|
||||||
expect(g.C).toEqual(['cchm', 'ch'])
|
expect(g.C).toEqual(['cchm', 'ch'])
|
||||||
expect(g.E).toEqual(['E', 'e'])
|
expect(g.E).toEqual(['E', 'e'])
|
||||||
expect(toDisplayString(g)).toBe(display)
|
expect(toDisplayString(g)).toBe(display)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
|
||||||
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
const display = 'aa Cc[h] dd EE Gg P- Spsp'
|
||||||
const g = fromDisplayString(display)
|
const g = fromDisplayString(display)
|
||||||
expect(g.C).toEqual(['C', 'ch'])
|
expect(g.C).toEqual(['C', 'ch'])
|
||||||
expect(g.D).toEqual(['d', 'd'])
|
expect(g.D).toEqual(['d', 'd'])
|
||||||
@@ -668,7 +672,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
||||||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||||
expect(g.E).toEqual(['e', '?'])
|
expect(g.E).toEqual(['e', '?'])
|
||||||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||||||
@@ -680,6 +684,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||||
expect(g.D).toEqual(['D', 'd'])
|
expect(g.D).toEqual(['D', 'd'])
|
||||||
expect(g.Sp).toEqual(['Sp', 'sp'])
|
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||||||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
|
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
||||||
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
||||||
// to the frozen-name rule; the ColorVariety seed drops it too.
|
// to the frozen-name rule; the ColorVariety seed drops it too.
|
||||||
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
{ name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||||
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
||||||
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
||||||
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
||||||
@@ -268,7 +268,7 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
|||||||
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
||||||
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
||||||
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
||||||
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
|
// ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here)
|
||||||
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
||||||
const [c0, c1] = resolvedPair(g, 'C')
|
const [c0, c1] = resolvedPair(g, 'C')
|
||||||
const [p0, p1] = resolvedPair(g, 'P')
|
const [p0, p1] = resolvedPair(g, 'P')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Pink Eyed White (PEW)",
|
"name": "REW",
|
||||||
"english": "Pink Eyed White",
|
"english": "Pink Eyed White",
|
||||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
|
|||||||
@@ -1,426 +1,426 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Pink Eyed White (PEW)",
|
"name": "REW",
|
||||||
"english": "Pink Eyed White",
|
"english": "Pink Eyed White",
|
||||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hermelin",
|
"name": "Hermelin",
|
||||||
"english": "Dark Tailed White",
|
"english": "Dark Tailed White",
|
||||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 1,
|
"sortOrder": 1,
|
||||||
"image": "hermelin.jpeg"
|
"image": "hermelin.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Himalaya",
|
"name": "Himalaya",
|
||||||
"english": "Himalayan",
|
"english": "Himalayan",
|
||||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 2,
|
"sortOrder": 2,
|
||||||
"image": "himalaya.jpg"
|
"image": "himalaya.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel",
|
"name": "Zobel",
|
||||||
"english": "Sable",
|
"english": "Sable",
|
||||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp",
|
||||||
"sortOrder": 3,
|
"sortOrder": 3,
|
||||||
"image": "zobel.jpeg"
|
"image": "zobel.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotaugenschimmel",
|
"name": "Rotaugenschimmel",
|
||||||
"english": "Red-Eyed Roan",
|
"english": "Red-Eyed Roan",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||||
"sortOrder": 4,
|
"sortOrder": 4,
|
||||||
"image": "rotaugen-schimmel.jpg"
|
"image": "rotaugen-schimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agouti",
|
"name": "Agouti",
|
||||||
"english": "Golden Agouti",
|
"english": "Golden Agouti",
|
||||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG PP spsp",
|
||||||
"sortOrder": 5,
|
"sortOrder": 5,
|
||||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Schwarz",
|
"name": "Schwarz",
|
||||||
"english": "Black",
|
"english": "Black",
|
||||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG PP spsp",
|
||||||
"sortOrder": 6,
|
"sortOrder": 6,
|
||||||
"image": "schwarz.jpg"
|
"image": "schwarz.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberagouti",
|
"name": "Silberagouti",
|
||||||
"english": "Grey Agouti",
|
"english": "Grey Agouti",
|
||||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD EE gg PP spsp",
|
||||||
"sortOrder": 7,
|
"sortOrder": 7,
|
||||||
"image": "silberagouti.jpg"
|
"image": "silberagouti.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Anthrazit",
|
"name": "Anthrazit",
|
||||||
"english": "Slate",
|
"english": "Slate",
|
||||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD EE gg PP spsp",
|
||||||
"sortOrder": 8,
|
"sortOrder": 8,
|
||||||
"image": "anthrazit.jpg"
|
"image": "anthrazit.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs",
|
"name": "Algierfuchs",
|
||||||
"english": "Dark-Eyed Honey",
|
"english": "Dark-Eyed Honey",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||||
"sortOrder": 9,
|
"sortOrder": 9,
|
||||||
"image": "algierfuchs.jpg"
|
"image": "algierfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blau",
|
"name": "Blau",
|
||||||
"english": "Blue",
|
"english": "Blue",
|
||||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG PP spsp",
|
||||||
"sortOrder": 10,
|
"sortOrder": 10,
|
||||||
"image": "blau-schwarz-dd.JPG"
|
"image": "blau-schwarz-dd.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold",
|
"name": "Gold",
|
||||||
"english": "Argente Golden",
|
"english": "Argente Golden",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||||
"sortOrder": 11,
|
"sortOrder": 11,
|
||||||
"image": "gold.jpg"
|
"image": "gold.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Platin",
|
"name": "Platin",
|
||||||
"english": "Lilac",
|
"english": "Lilac",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||||
"sortOrder": 12,
|
"sortOrder": 12,
|
||||||
"image": "platin.JPG"
|
"image": "platin.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs",
|
"name": "Goldfuchs",
|
||||||
"english": "Yellow Fox",
|
"english": "Yellow Fox",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||||
"sortOrder": 13,
|
"sortOrder": 13,
|
||||||
"image": "goldfuchs.jpg"
|
"image": "goldfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs",
|
"name": "Rotfuchs",
|
||||||
"english": "Argente Nutmeg",
|
"english": "Argente Nutmeg",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||||
"sortOrder": 14,
|
"sortOrder": 14,
|
||||||
"image": "rotfuchs.JPG"
|
"image": "rotfuchs.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Gold",
|
"name": "Dilute Gold",
|
||||||
"english": "dd Argente Golden",
|
"english": "dd Argente Golden",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||||
"sortOrder": 15,
|
"sortOrder": 15,
|
||||||
"image": "gold-dd.jpg"
|
"image": "gold-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Platin",
|
"name": "Dilute Platin",
|
||||||
"english": "dd Lilac",
|
"english": "dd Lilac",
|
||||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG pp spsp",
|
||||||
"sortOrder": 16,
|
"sortOrder": 16,
|
||||||
"image": "platin-dd.jpg"
|
"image": "platin-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Altweiss (REW)",
|
"name": "Altweiss (REW)",
|
||||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE gg pp spsp",
|
||||||
"sortOrder": 17,
|
"sortOrder": 17,
|
||||||
"image": "altweiss-rew.jpeg"
|
"image": "altweiss-rew.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Apricot (Blassfuchs)",
|
"name": "Apricot (Blassfuchs)",
|
||||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg pp spsp",
|
||||||
"sortOrder": 18,
|
"sortOrder": 18,
|
||||||
"image": "apricot-blassfuchs.jpg"
|
"image": "apricot-blassfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs",
|
"name": "Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||||
"sortOrder": 19,
|
"sortOrder": 19,
|
||||||
"image": "blaufuchs.jpg"
|
"image": "blaufuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "C-Separator",
|
"name": "C-Separator",
|
||||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg pp spsp",
|
||||||
"sortOrder": 20,
|
"sortOrder": 20,
|
||||||
"image": "c-separator.jpg"
|
"image": "c-separator.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Elfenbein",
|
"name": "Elfenbein",
|
||||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE gg pp spsp",
|
||||||
"sortOrder": 21,
|
"sortOrder": 21,
|
||||||
"image": "elfenbein.jpg"
|
"image": "elfenbein.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs",
|
"name": "Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||||
"sortOrder": 22,
|
"sortOrder": 22,
|
||||||
"image": "kohlfuchs.jpg"
|
"image": "kohlfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs",
|
"name": "Polarfuchs",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||||
"sortOrder": 23,
|
"sortOrder": 23,
|
||||||
"image": "polarfuchs.jpg"
|
"image": "polarfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Saphir",
|
"name": "Saphir",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||||
"sortOrder": 24,
|
"sortOrder": 24,
|
||||||
"image": "saphir.jpg"
|
"image": "saphir.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Orangeschimmel",
|
"name": "Orangeschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 25,
|
"sortOrder": 25,
|
||||||
"image": "schimmel-orangeschimmel.jpg"
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas",
|
"name": "Topas",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||||
"sortOrder": 26,
|
"sortOrder": 26,
|
||||||
"image": "topas.jpg"
|
"image": "topas.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Platin-Hell",
|
"name": "Platin-Hell",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||||
"sortOrder": 27,
|
"sortOrder": 27,
|
||||||
"image": "platin-hell.jpg"
|
"image": "platin-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Agouti",
|
"name": "Dilute Agouti",
|
||||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG PP spsp",
|
||||||
"sortOrder": 28,
|
"sortOrder": 28,
|
||||||
"image": "agouti-dd.jpg"
|
"image": "agouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Silberagouti",
|
"name": "Dilute Silberagouti",
|
||||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE gg PP spsp",
|
||||||
"sortOrder": 29,
|
"sortOrder": 29,
|
||||||
"image": "silberagouti-dd.jpg"
|
"image": "silberagouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Kohlfuchs",
|
"name": "Dilute Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG PP spsp",
|
||||||
"sortOrder": 30,
|
"sortOrder": 30,
|
||||||
"image": "kohlfuchs-dd.jpg"
|
"image": "kohlfuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Anthrazit",
|
"name": "Dilute Anthrazit",
|
||||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE gg PP spsp",
|
||||||
"sortOrder": 31,
|
"sortOrder": 31,
|
||||||
"image": "anthrazit-dd.jpg"
|
"image": "anthrazit-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Algierfuchs",
|
"name": "Dilute Algierfuchs",
|
||||||
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd ee GG PP spsp",
|
||||||
"sortOrder": 32
|
"sortOrder": 32
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Goldfuchs",
|
"name": "Dilute Goldfuchs",
|
||||||
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd ee GG pp spsp",
|
||||||
"sortOrder": 33
|
"sortOrder": 33
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Rotfuchs",
|
"name": "Dilute Rotfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG pp spsp",
|
||||||
"sortOrder": 34
|
"sortOrder": 34
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Polarfuchs",
|
"name": "Dilute Polarfuchs",
|
||||||
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd ee gg PP spsp",
|
||||||
"sortOrder": 35
|
"sortOrder": 35
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"name": "Silberschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||||
"sortOrder": 36,
|
"sortOrder": 36,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||||
"sortOrder": 37,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 38,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
||||||
"sortOrder": 40,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs, hell",
|
"name": "Kohlfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||||
"sortOrder": 41,
|
"sortOrder": 41,
|
||||||
"image": "kohlfuchs-hell.jpg"
|
"image": "kohlfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs, hell",
|
"name": "Goldfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||||
"sortOrder": 42,
|
"sortOrder": 42,
|
||||||
"image": "goldfuchs-hell.jpg"
|
"image": "goldfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||||
"sortOrder": 43,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold-Hell",
|
"name": "Gold-Hell",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||||
"sortOrder": 44,
|
"sortOrder": 44,
|
||||||
"image": "gold-hell.jpg"
|
"image": "gold-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs, hell",
|
"name": "Blaufuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||||
"sortOrder": 45,
|
"sortOrder": 45,
|
||||||
"image": "blaufuchs-hell.jpeg"
|
"image": "blaufuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
||||||
"sortOrder": 46,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs, hell",
|
"name": "Polarfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||||
"sortOrder": 47,
|
"sortOrder": 47,
|
||||||
"image": "polarfuchs-hell.jpeg"
|
"image": "polarfuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 48,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs, hell",
|
"name": "Rotfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||||
"sortOrder": 49,
|
"sortOrder": 49,
|
||||||
"image": "rotfuchs-hell.jpg"
|
"image": "rotfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs-Hell",
|
"name": "Kohlfuchs-Hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||||
"sortOrder": 50,
|
"sortOrder": 50,
|
||||||
"image": "kohlfuchs-hell-2.jpg"
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs, hell",
|
"name": "Algierfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||||
"sortOrder": 51,
|
"sortOrder": 51,
|
||||||
"image": "algierfuchs-hell.JPG"
|
"image": "algierfuchs-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Topas",
|
"name": "Dilute Topas",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||||
"sortOrder": 52,
|
"sortOrder": 52,
|
||||||
"image": "topas-dd.jpg"
|
"image": "topas-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Blaufuchs",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marder",
|
"name": "Marder",
|
||||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp",
|
||||||
"sortOrder": 54,
|
"sortOrder": 54,
|
||||||
"image": "marder.JPG"
|
"image": "marder.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Siam",
|
"name": "Siam",
|
||||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 55,
|
"sortOrder": 55,
|
||||||
"image": "siam-marder-hell.JPG"
|
"image": "siam-marder-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel-Hell",
|
"name": "Zobel-Hell",
|
||||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp",
|
||||||
"sortOrder": 56,
|
"sortOrder": 56,
|
||||||
"image": "zobel-hell.jpg"
|
"image": "zobel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti",
|
"name": "CP-Agouti",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp",
|
||||||
"sortOrder": 57,
|
"sortOrder": 57,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti-Hell",
|
"name": "CP-Agouti-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 58
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp",
|
||||||
"sortOrder": 59,
|
"sortOrder": 59,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti-Hell",
|
"name": "CP-Silberagouti-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp",
|
||||||
"sortOrder": 60
|
"sortOrder": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp",
|
||||||
"sortOrder": 61,
|
"sortOrder": 61,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs-Hell",
|
"name": "CP-Algierfuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp",
|
||||||
"sortOrder": 62
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp",
|
||||||
"sortOrder": 63,
|
"sortOrder": 63,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs-Hell",
|
"name": "CP-Polarfuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp",
|
||||||
"sortOrder": 64
|
"sortOrder": 64
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp",
|
||||||
"sortOrder": 65
|
"sortOrder": 65
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"name": "CP-Fuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp",
|
||||||
"sortOrder": 66
|
"sortOrder": 66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"name": "CP-Blaufuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp",
|
||||||
"sortOrder": 67
|
"sortOrder": 67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"name": "CP-Orangeschimmel",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 68
|
"sortOrder": 68
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel-Hell",
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 69
|
"sortOrder": 69
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
|
||||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
|
||||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
|
||||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
|
||||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
* 8-locus strings and the colour catalog stay byte-identical; it only appears for
|
||||||
* convention) — e.g. ['C','?'] renders "C-".
|
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
|
||||||
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
|
||||||
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
* convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
|
||||||
|
* order E > e > e[f].
|
||||||
*/
|
*/
|
||||||
export function toDisplayString(g: Genotype): string {
|
export function toDisplayString(g: Genotype): string {
|
||||||
return LOCUS_ORDER.filter(
|
return LOCUS_ORDER.filter(
|
||||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
(locus) =>
|
||||||
|
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
|
||||||
|
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
)
|
)
|
||||||
.map((locus) => {
|
.map((locus) => {
|
||||||
const [a, b] = displayPair(locus, g[locus])
|
const [a, b] = displayPair(locus, g[locus])
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import Tree from 'react-d3-tree'
|
|||||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
|
import { listLitters } from '../api/litters'
|
||||||
import { listColorVarieties } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
import { getInbreedingCoefficient } from '../api/pedigree'
|
import { getInbreedingCoefficient } from '../api/pedigree'
|
||||||
import type { Gender, Gerbil } from '../api/types'
|
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel } from '../format/labels'
|
import { formatDate, genderLabel } from '../format/labels'
|
||||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||||
import {
|
import {
|
||||||
DEFAULT_GENERATIONS,
|
DEFAULT_GENERATIONS,
|
||||||
ancestorsAt,
|
ancestorsAt,
|
||||||
@@ -156,6 +157,13 @@ export default function StammbaumPage() {
|
|||||||
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
|
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
|
||||||
: t.inbreeding.unavailable
|
: t.inbreeding.unavailable
|
||||||
|
|
||||||
|
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
|
||||||
|
const rootLitters = useApi(
|
||||||
|
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
const rootLitterItems = rootLitters.data?.items ?? []
|
||||||
|
|
||||||
/* ── react-d3-tree-Daten ── */
|
/* ── react-d3-tree-Daten ── */
|
||||||
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
||||||
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
||||||
@@ -315,27 +323,36 @@ export default function StammbaumPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stammbaum-canvas" ref={canvasRef}>
|
<div className="stammbaum-layout">
|
||||||
{view && (
|
{rootLitterItems.length > 0 && (
|
||||||
<Tree
|
<LittersPanel litters={rootLitterItems} t={t} />
|
||||||
key={id}
|
|
||||||
data={datum}
|
|
||||||
renderCustomNodeElement={renderNode}
|
|
||||||
orientation="horizontal"
|
|
||||||
pathFunc="step"
|
|
||||||
translate={view.translate}
|
|
||||||
zoom={view.zoom}
|
|
||||||
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
|
|
||||||
zoomable
|
|
||||||
draggable
|
|
||||||
collapsible={false}
|
|
||||||
nodeSize={{ x: NODE_X, y: NODE_Y }}
|
|
||||||
separation={{ siblings: 1, nonSiblings: 1 }}
|
|
||||||
onUpdate={handleTreeUpdate}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="stammbaum-canvas" ref={canvasRef}>
|
||||||
|
{view && (
|
||||||
|
<Tree
|
||||||
|
key={id}
|
||||||
|
data={datum}
|
||||||
|
renderCustomNodeElement={renderNode}
|
||||||
|
orientation="horizontal"
|
||||||
|
pathFunc="step"
|
||||||
|
translate={view.translate}
|
||||||
|
zoom={view.zoom}
|
||||||
|
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
|
||||||
|
zoomable
|
||||||
|
draggable
|
||||||
|
collapsible={false}
|
||||||
|
nodeSize={{ x: NODE_X, y: NODE_Y }}
|
||||||
|
separation={{ siblings: 1, nonSiblings: 1 }}
|
||||||
|
onUpdate={handleTreeUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="stammbaum-hint">{t.tapHint}</p>
|
<ul className="stammbaum-hints">
|
||||||
|
<li>{t.tapHint}</li>
|
||||||
|
<li>{t.hintName}</li>
|
||||||
|
<li>{t.hintExpand}</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
||||||
@@ -377,7 +394,13 @@ function PedigreeCard({
|
|||||||
<div className="pedigree-card__body">
|
<div className="pedigree-card__body">
|
||||||
<div className="pedigree-card__name">
|
<div className="pedigree-card__name">
|
||||||
<SexIcon gender={g.gender} />
|
<SexIcon gender={g.gender} />
|
||||||
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
<Link
|
||||||
|
to={`/rennmaeuse/${g.id}`}
|
||||||
|
className="pedigree-card__nametext"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{g.name || de.pages.gerbils.nameless}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
@@ -407,6 +430,36 @@ function PedigreeCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
|
||||||
|
|
||||||
|
function LittersPanel({
|
||||||
|
litters,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
litters: Litter[]
|
||||||
|
t: { littersTitle: string; littersJunge: string }
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
|
||||||
|
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
|
||||||
|
<ul className="stammbaum-litters-panel__list">
|
||||||
|
{litters.map((l) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
|
||||||
|
<span className="stammbaum-litters-panel__name">{l.name}</span>
|
||||||
|
{l.totalBorn != null && (
|
||||||
|
<span className="stammbaum-litters-panel__born">
|
||||||
|
{l.totalBorn} {t.littersJunge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SexIcon({ gender }: { gender: Gender }) {
|
function SexIcon({ gender }: { gender: Gender }) {
|
||||||
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
|
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
|
||||||
return (
|
return (
|
||||||
@@ -502,7 +555,11 @@ function PrintCell({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
|
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
|
||||||
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
|
{g.genotype && gen <= 2 && (
|
||||||
|
<div className="stammbaum-print__geno">
|
||||||
|
{toDisplayString(fromDisplayString(g.genotype))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ export default function WurfDetailPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3>{t.detail.expectedColors}</h3>
|
|
||||||
{expected ? (
|
{expected ? (
|
||||||
<BreedingResultView result={expected} title={t.detail.expectedColors} />
|
<BreedingResultView result={expected} title={t.detail.expectedColors} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -34,15 +34,143 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stammbaum-hint {
|
.stammbaum-hints {
|
||||||
font-size: 0.8rem;
|
list-style: none;
|
||||||
color: var(--color-text-muted);
|
padding: 0;
|
||||||
margin: 0.4rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
|
||||||
|
|
||||||
|
.stammbaum-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: Würfe-Panel links vom Baum. */
|
||||||
|
.stammbaum-litters-panel {
|
||||||
|
flex: none;
|
||||||
|
width: 148px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__link:hover {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__name {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__born {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stammbaum-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel {
|
||||||
|
width: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__title {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__list {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-height: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__link {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__born {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas behält explizite Höhe in column-Richtung. */
|
||||||
|
.stammbaum-canvas {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.stammbaum-canvas {
|
.stammbaum-canvas {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
height: clamp(18rem, 62dvh, 46rem);
|
height: clamp(18rem, 62dvh, 46rem);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
@@ -134,6 +262,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__nametext:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pedigree-card__sex--male {
|
.pedigree-card__sex--male {
|
||||||
@@ -169,20 +303,27 @@
|
|||||||
.pedigree-card__expand {
|
.pedigree-card__expand {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--color-accent);
|
border: none;
|
||||||
background: var(--color-accent-soft);
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pedigree-card__expand:hover {
|
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 5px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__expand:hover,
|
||||||
|
.pedigree-card__expand:focus-visible {
|
||||||
|
opacity: 0.82;
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface ChipColor {
|
|||||||
|
|
||||||
const CHIP_COLORS: Record<string, ChipColor> = {
|
const CHIP_COLORS: Record<string, ChipColor> = {
|
||||||
// Colourpoint / C-Serie
|
// Colourpoint / C-Serie
|
||||||
|
// GEN-4c: PEW renamed to REW in catalog (Julian: PEW=REW). Both kept here for
|
||||||
|
// backward-compat during DB migration (old animals may still carry the old name).
|
||||||
|
'REW': { bg: '#f7f3ec', fg: '#8a7d6b' },
|
||||||
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
|
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
|
||||||
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
|
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
|
||||||
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },
|
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },
|
||||||
|
|||||||
@@ -355,6 +355,12 @@ export const de = {
|
|||||||
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
||||||
expand: 'Vorfahren laden',
|
expand: 'Vorfahren laden',
|
||||||
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
||||||
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
|
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
|
||||||
|
littersTitle: 'Würfe',
|
||||||
|
littersJunge: 'Junge',
|
||||||
zoomIn: 'Vergrößern',
|
zoomIn: 'Vergrößern',
|
||||||
zoomOut: 'Verkleinern',
|
zoomOut: 'Verkleinern',
|
||||||
zoomFit: 'Ansicht einpassen',
|
zoomFit: 'Ansicht einpassen',
|
||||||
@@ -784,9 +790,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||||
},
|
},
|
||||||
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
|
||||||
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
|
||||||
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
|
||||||
character: {
|
character: {
|
||||||
sectionTitle: 'Charakter & Eigenschaften',
|
sectionTitle: 'Charakter & Eigenschaften',
|
||||||
noteLabel: 'Notizen zum Charakter',
|
noteLabel: 'Notizen zum Charakter',
|
||||||
@@ -794,22 +800,53 @@ export const de = {
|
|||||||
save: 'Charakter speichern',
|
save: 'Charakter speichern',
|
||||||
saved: 'Charakter gespeichert.',
|
saved: 'Charakter gespeichert.',
|
||||||
none: 'Noch keine Eigenschaften ausgewählt.',
|
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||||
traits: [
|
warnLabel: '⚠ Warnsignal',
|
||||||
{ key: 'zutraulich', label: 'zutraulich' },
|
traitCategories: [
|
||||||
{ key: 'handzahm', label: 'handzahm' },
|
{
|
||||||
{ key: 'neugierig', label: 'neugierig' },
|
category: 'Sozialverhalten',
|
||||||
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
traits: [
|
||||||
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
{ key: 'dominant', label: 'dominant (Leittier)' },
|
||||||
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
|
||||||
{ key: 'verschmust', label: 'verschmust' },
|
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
|
||||||
{ key: 'eigenstaendig', label: 'eigenständig' },
|
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
|
||||||
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
],
|
||||||
{ key: 'futterfreudig', label: 'futterfreudig' },
|
},
|
||||||
{ key: 'buddelt', label: 'buddelt gern' },
|
{
|
||||||
{ key: 'klettert', label: 'klettert gern' },
|
category: 'Eignung & Umgang',
|
||||||
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
traits: [
|
||||||
{ key: 'vertraeglich', label: 'gut verträglich' },
|
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'erfahrene-halter', label: 'für erfahrene Halter' },
|
||||||
|
{ key: 'beobachtungstier', label: 'reines Beobachtungstier' },
|
||||||
|
{ key: 'familiengeeignet', label: 'familiengeeignet / stressresistent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Hobbys & Eigenarten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||||
|
{ key: 'buddelt', label: 'buddelt gern' },
|
||||||
|
{ key: 'klettert', label: 'klettert gern' },
|
||||||
|
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||||
|
{ key: 'schredder', label: 'Schredder-Meister' },
|
||||||
|
{ key: 'nestbauer', label: 'Nestbauer / Architekt' },
|
||||||
|
{ key: 'territorial', label: 'territorial', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Wesen & Temperament',
|
||||||
|
traits: [
|
||||||
|
{ key: 'zutraulich', label: 'zutraulich' },
|
||||||
|
{ key: 'handzahm', label: 'handzahm' },
|
||||||
|
{ key: 'neugierig', label: 'neugierig' },
|
||||||
|
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
||||||
|
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
||||||
|
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
||||||
|
{ key: 'verschmust', label: 'verschmust' },
|
||||||
|
{ key: 'eigenstaendig', label: 'eigenständig' },
|
||||||
|
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||||
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
|
|||||||
@@ -112,6 +112,56 @@
|
|||||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hanami von den Kleinen Chaoten",
|
||||||
|
"dob": "10.09.2015",
|
||||||
|
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||||
|
"dateOfDeath": "12.12.2019",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kazumi von den Kleinen Chaoten",
|
||||||
|
"dob": "23.04.2013",
|
||||||
|
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
|
||||||
|
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Filou von den Kleinen Chaoten",
|
||||||
|
"dob": "24.11.2014",
|
||||||
|
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
|
||||||
|
"dateOfDeath": "31.08.2019",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sokrates von den Kleinen Chaoten",
|
||||||
|
"dob": "14.12.2015",
|
||||||
|
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
|
||||||
|
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
|
||||||
|
"dateOfDeath": "20.05.2019",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Percy of little runners",
|
||||||
|
"dob": "16.12.2017",
|
||||||
|
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||||
|
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Iwana of little runners",
|
||||||
|
"dob": "02.10.2018",
|
||||||
|
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||||
|
"genotype": "Aa CC DD Ee Gg Pp spsp",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Osamu von den Kleinen Chaoten",
|
||||||
|
"dob": "10.12.2015",
|
||||||
|
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
|
||||||
|
"dateOfDeath": "18.12.2020",
|
||||||
|
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
312
tools/import/extract_docx.py
Normal file
312
tools/import/extract_docx.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""FEAT-8d Stage 1 — Wurfchronik-Detail-Dokument (.docx) extrahieren.
|
||||||
|
|
||||||
|
Liest 'Wurfchronik der Kleinen Chaoten im Detail.docx' (Word/XML, stdlib-Python,
|
||||||
|
kein pip) und erzeugt:
|
||||||
|
output/docx_litters.json — Wurf-Kopfdaten (WS-Code, DOB, Eltern, Notiz)
|
||||||
|
output/docx_animals.json — Tier-Zeilen (Name, Farbe, Abnehmer, ABD, Tod)
|
||||||
|
|
||||||
|
Format der Ausgabe ist so gestaltet, dass ImportDocxService.cs in C# direkt
|
||||||
|
darüber laden kann. Idempotent: mehrfaches Ausführen überschreibt denselben Output.
|
||||||
|
|
||||||
|
Bekannte Sonderwerte im Dokument:
|
||||||
|
ZT = Zucht-Tier (bleibt in Zucht, kein externer Abnehmer)
|
||||||
|
BLEIBT = vorläufig beim Züchter
|
||||||
|
FREI = noch verfügbar
|
||||||
|
VG: = Verpaarungs-Geschichte (bisherige Partner; nicht als Abnehmer werten)
|
||||||
|
RG: = Rückgabe
|
||||||
|
BEW = Bewerbung (Adoptionsinteressent in Prüfung)
|
||||||
|
-- ??? = Platzhalter, kein echter Name
|
||||||
|
|
||||||
|
Feld 'gender': '' = weiblich (kein Marker), '*' auf Farbschlag oder 'G'-Spalte = männlich.
|
||||||
|
|
||||||
|
Ausführung: python extract_docx.py [--docx PFAD]
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_DOCX = os.path.join(
|
||||||
|
r"C:\Users\gulum\dev",
|
||||||
|
"Wurfchronik der Kleinen Chaoten im Detail.docx",
|
||||||
|
)
|
||||||
|
OUT = os.path.join(HERE, "output")
|
||||||
|
|
||||||
|
# --- Regex patterns -------------------------------------------------------
|
||||||
|
|
||||||
|
# Litter header paragraph (after whitespace-collapsing).
|
||||||
|
# Edge cases handled:
|
||||||
|
# - Dual birth date: "*16./17.03.2021"
|
||||||
|
# - WS without numerator: "WS: /5"
|
||||||
|
# - WS with trailing text: "WS: 4/4, davon 1 später..."
|
||||||
|
# - No space before WS: "...ChaotenWS: 2/4"
|
||||||
|
# Date part allows simple DD.MM.YYYY, dual-day (16./17.03.2021), or dual-month (31.05/*01.06.2023).
|
||||||
|
# We capture the LAST complete DD.MM.YYYY in the date token as the birth date.
|
||||||
|
_DATE_TOKEN = r"[\d./\*]+"
|
||||||
|
# Full litter header regex
|
||||||
|
LITTER_RE = re.compile(
|
||||||
|
r"([A-Za-z\d\-]*Wurf)\s*\*\s*(" + _DATE_TOKEN + r")"
|
||||||
|
r"\s*Von:\s*(.+?)\s*&\s*(.+?)\s*WS:\s*(\d*\s*/\s*\d+)"
|
||||||
|
r"(?:[,\s].*?)?(?:Notiz:\s*(.*?))?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Used to extract the canonical date from a date token like "31.05/*01.06.2023"
|
||||||
|
_LAST_DATE_RE = re.compile(r"(\d{1,2}\.\d{2}\.\d{4})(?![\d.])")
|
||||||
|
# Death/adoption date at start of combined T.D column: "16.09.23Tumor am After"
|
||||||
|
DATE_START_RE = re.compile(r"^(\d{1,2}\.\d{1,2}\.\d{2,4})\s*(.*)")
|
||||||
|
# Partner birth date: "Crow (*25.12.20)" or "Tom (*05.01.21)"
|
||||||
|
PARTNER_DOB_RE = re.compile(r"\(\s*\*\s*(\d{2}\.\d{2}\.\d{2,4})\s*\)")
|
||||||
|
# Special-value sentinel names to skip
|
||||||
|
PLACEHOLDER_NAMES = {"--", "???", ""}
|
||||||
|
INTERNAL_TOKENS = {"ZT", "BLEIBT", "FREI", "VG:", "VG*:", "RG:", "BEW"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_dob(d: str) -> str:
|
||||||
|
"""Normalise German date to DD.MM.YYYY."""
|
||||||
|
if not d:
|
||||||
|
return ""
|
||||||
|
p = d.strip().split(".")
|
||||||
|
if len(p) == 3:
|
||||||
|
y = p[2].strip()
|
||||||
|
if len(y) == 2:
|
||||||
|
y = "20" + y
|
||||||
|
return f"{p[0].zfill(2)}.{p[1].zfill(2)}.{y}"
|
||||||
|
return d.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_text(cell_xml: str) -> str:
|
||||||
|
"""Strip XML from a <w:tc> cell and return clean text."""
|
||||||
|
t = re.sub(r"<[^>]+>", "", cell_xml)
|
||||||
|
t = t.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
t = t.replace("'", "'").replace(""", '"')
|
||||||
|
return re.sub(r"\s+", " ", t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal(value: str) -> bool:
|
||||||
|
"""True if the owner/name field holds an internal sentinel, not a real person."""
|
||||||
|
v = value.strip()
|
||||||
|
return v in INTERNAL_TOKENS or any(v.startswith(tok) for tok in INTERNAL_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(docx_path: str):
|
||||||
|
"""Parse the docx and return (litters, animals) lists."""
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
xml = z.read("word/document.xml").decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# ---- Paragraphs → litter header blocks ----
|
||||||
|
paras = re.findall(r"<w:p[ >].*?</w:p>", xml, re.DOTALL)
|
||||||
|
para_texts = []
|
||||||
|
for p in paras:
|
||||||
|
t = re.sub(r"<[^>]+>", "", p)
|
||||||
|
t = t.replace("&", "&").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
if t:
|
||||||
|
para_texts.append(t)
|
||||||
|
|
||||||
|
litters: list[dict] = []
|
||||||
|
current_ws: str = ""
|
||||||
|
current_litter_dob: str = ""
|
||||||
|
|
||||||
|
# Build a WS-code → litter index for assigning animals
|
||||||
|
ws_to_idx: dict[str, int] = {}
|
||||||
|
|
||||||
|
for para in para_texts:
|
||||||
|
m = LITTER_RE.search(para)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
litter_id = m.group(1).strip()
|
||||||
|
dob_raw = m.group(2).strip()
|
||||||
|
mother_raw = m.group(3).strip()
|
||||||
|
father_raw = m.group(4).strip()
|
||||||
|
ws_raw = m.group(5).replace(" ", "")
|
||||||
|
note = (m.group(6) or "").strip()
|
||||||
|
|
||||||
|
# For dual-date tokens like "31.05/*01.06.2023", take the last full date.
|
||||||
|
last_dates = _LAST_DATE_RE.findall(dob_raw)
|
||||||
|
dob_clean = _norm_dob(last_dates[-1] if last_dates else dob_raw)
|
||||||
|
|
||||||
|
litter = {
|
||||||
|
"litterId": litter_id,
|
||||||
|
"dob": dob_clean,
|
||||||
|
"motherName": mother_raw,
|
||||||
|
"fatherName": father_raw,
|
||||||
|
"wsCode": ws_raw,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
ws_to_idx[ws_raw] = len(litters)
|
||||||
|
litters.append(litter)
|
||||||
|
|
||||||
|
# ---- Tables → animal rows ----
|
||||||
|
# Each table sits after a litter-header paragraph; we sequence tables and
|
||||||
|
# litter headers together by their byte offset in the XML.
|
||||||
|
animals: list[dict] = []
|
||||||
|
|
||||||
|
# Build ordered sequence of (offset, type, data) events
|
||||||
|
events: list[tuple[int, str, any]] = []
|
||||||
|
for m in re.finditer(r"<w:p[ >].*?</w:p>", xml, re.DOTALL):
|
||||||
|
t = re.sub(r"<[^>]+>", "", m.group()).replace("&", "&").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
lm = LITTER_RE.search(t)
|
||||||
|
if lm:
|
||||||
|
ws = lm.group(5).replace(" ", "")
|
||||||
|
dob_tok = lm.group(2)
|
||||||
|
last = _LAST_DATE_RE.findall(dob_tok)
|
||||||
|
dob = _norm_dob(last[-1] if last else dob_tok)
|
||||||
|
events.append((m.start(), "litter", (ws, dob)))
|
||||||
|
|
||||||
|
for m in re.finditer(r"<w:tbl[ >].*?</w:tbl>", xml, re.DOTALL):
|
||||||
|
events.append((m.start(), "table", m.group()))
|
||||||
|
|
||||||
|
events.sort(key=lambda e: e[0])
|
||||||
|
|
||||||
|
active_ws = ""
|
||||||
|
active_dob = ""
|
||||||
|
|
||||||
|
for _, etype, edata in events:
|
||||||
|
if etype == "litter":
|
||||||
|
active_ws, active_dob = edata
|
||||||
|
elif etype == "table" and active_ws:
|
||||||
|
# Parse all rows in this table
|
||||||
|
rows = re.findall(r"<w:tr[ >].*?</w:tr>", edata, re.DOTALL)
|
||||||
|
for row in rows:
|
||||||
|
cells_xml = re.findall(r"<w:tc[ >].*?</w:tc>", row, re.DOTALL)
|
||||||
|
ct = [_cell_text(c) for c in cells_xml]
|
||||||
|
if not ct:
|
||||||
|
continue
|
||||||
|
# Skip header rows
|
||||||
|
if ct[0] == "G" and len(ct) > 1 and "Farbe" in ct[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Column positions: G | Farbe | Name | Partner | Abnehmer | ABD | T.D
|
||||||
|
# Some newer tables add ABGew between ABD and T.D (7 or 8 cols)
|
||||||
|
g_col = ct[0] if len(ct) > 0 else ""
|
||||||
|
farbe_raw = ct[1] if len(ct) > 1 else ""
|
||||||
|
name = ct[2] if len(ct) > 2 else ""
|
||||||
|
partner = ct[3] if len(ct) > 3 else ""
|
||||||
|
owner = ct[4] if len(ct) > 4 else ""
|
||||||
|
abd_raw = ct[5] if len(ct) > 5 else ""
|
||||||
|
# If 8 cols, col 6 = ABGew, col 7 = T.D; if 7 cols, col 6 = T.D
|
||||||
|
if len(ct) >= 8:
|
||||||
|
abgew = ct[6]
|
||||||
|
tod_raw = ct[7]
|
||||||
|
elif len(ct) >= 7:
|
||||||
|
abgew = ""
|
||||||
|
tod_raw = ct[6]
|
||||||
|
else:
|
||||||
|
abgew = ""
|
||||||
|
tod_raw = ""
|
||||||
|
|
||||||
|
# Skip placeholders
|
||||||
|
name = name.strip()
|
||||||
|
if name in PLACEHOLDER_NAMES:
|
||||||
|
continue
|
||||||
|
if not farbe_raw.strip() and not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Gender: explicit marker in G column, or * suffix on Farbschlag
|
||||||
|
is_male = bool(g_col.strip() == "*" or farbe_raw.endswith("*"))
|
||||||
|
farbschlag = farbe_raw.rstrip("*").strip()
|
||||||
|
|
||||||
|
# Owner: strip internal sentinels
|
||||||
|
owner_clean = owner.strip()
|
||||||
|
if _is_internal(owner_clean):
|
||||||
|
owner_clean = ""
|
||||||
|
# For multi-owner ("1.) Julia2.) RG:"), take first
|
||||||
|
m1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", owner_clean)
|
||||||
|
if m1:
|
||||||
|
owner_clean = m1.group(1).strip()
|
||||||
|
|
||||||
|
# ABD (Abgabe-Datum)
|
||||||
|
abgabe_date = _norm_dob(abd_raw.strip())
|
||||||
|
|
||||||
|
# T.D column: may start with a date followed by cause
|
||||||
|
death_date = ""
|
||||||
|
death_cause = ""
|
||||||
|
if tod_raw:
|
||||||
|
dm = DATE_START_RE.match(tod_raw.strip())
|
||||||
|
if dm:
|
||||||
|
death_date = _norm_dob(dm.group(1))
|
||||||
|
death_cause = dm.group(2).strip()
|
||||||
|
else:
|
||||||
|
death_cause = tod_raw.strip()
|
||||||
|
|
||||||
|
# Partner name and DOB
|
||||||
|
partner_clean = partner.strip()
|
||||||
|
partner_dob = ""
|
||||||
|
pdob_m = PARTNER_DOB_RE.search(partner_clean)
|
||||||
|
if pdob_m:
|
||||||
|
partner_dob = _norm_dob(pdob_m.group(1))
|
||||||
|
partner_clean = PARTNER_DOB_RE.sub("", partner_clean).strip()
|
||||||
|
# Strip VG:/ZT/etc. prefixes
|
||||||
|
partner_clean = re.sub(r"^(?:VG\*?:|ZT\s*)", "", partner_clean).strip()
|
||||||
|
# Take first partner in numbered list
|
||||||
|
pm1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", partner_clean)
|
||||||
|
if pm1:
|
||||||
|
partner_clean = pm1.group(1).strip()
|
||||||
|
|
||||||
|
animals.append({
|
||||||
|
"wsCode": active_ws,
|
||||||
|
"litterDob": active_dob,
|
||||||
|
"name": name,
|
||||||
|
"farbschlag": farbschlag,
|
||||||
|
"gender": "male" if is_male else "female",
|
||||||
|
"owner": owner_clean,
|
||||||
|
"abgabeDate": abgabe_date,
|
||||||
|
"abgabeWeight": abgew.strip(),
|
||||||
|
"deathDate": death_date,
|
||||||
|
"deathCause": death_cause,
|
||||||
|
"partnerName": partner_clean,
|
||||||
|
"partnerDob": partner_dob,
|
||||||
|
})
|
||||||
|
|
||||||
|
return litters, animals
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="FEAT-8d docx extractor")
|
||||||
|
ap.add_argument("--docx", default=DEFAULT_DOCX,
|
||||||
|
help="Pfad zur 'im Detail.docx'")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isfile(args.docx):
|
||||||
|
print(f"Fehler: Datei nicht gefunden: {args.docx}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Lese: {args.docx}")
|
||||||
|
litters, animals = extract(args.docx)
|
||||||
|
|
||||||
|
litters_path = os.path.join(OUT, "docx_litters.json")
|
||||||
|
animals_path = os.path.join(OUT, "docx_animals.json")
|
||||||
|
|
||||||
|
with open(litters_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(litters, f, ensure_ascii=False, indent=2)
|
||||||
|
with open(animals_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(animals, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
named = sum(1 for a in animals if a["name"])
|
||||||
|
with_owner = sum(1 for a in animals if a["owner"])
|
||||||
|
with_death = sum(1 for a in animals if a["deathDate"])
|
||||||
|
with_abgabe = sum(1 for a in animals if a["abgabeDate"])
|
||||||
|
|
||||||
|
print(f"Würfe: {len(litters)}")
|
||||||
|
print(f"Tiere: {len(animals)} (benannt: {named})")
|
||||||
|
print(f" mit Abnehmer: {with_owner}")
|
||||||
|
print(f" mit Abgabe-Dat: {with_abgabe}")
|
||||||
|
print(f" mit Tod-Datum: {with_death}")
|
||||||
|
print(f"Ausgabe: {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
77
tools/import/test_extract_docx.py
Normal file
77
tools/import/test_extract_docx.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for extract_docx.py — run: python test_extract_docx.py"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Require the docx to exist; skip if not present (CI won't have it)
|
||||||
|
DOCX = os.path.join(r"C:\Users\gulum\dev",
|
||||||
|
"Wurfchronik der Kleinen Chaoten im Detail.docx")
|
||||||
|
SKIP = not os.path.isfile(DOCX)
|
||||||
|
|
||||||
|
import extract_docx as ed
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
global failed
|
||||||
|
print(("ok: " if cond else "FAIL: ") + name)
|
||||||
|
if not cond:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- _norm_dob ---
|
||||||
|
check("norm_dob 2-digit year", ed._norm_dob("12.09.21") == "12.09.2021")
|
||||||
|
check("norm_dob 4-digit year", ed._norm_dob("07.04.2019") == "07.04.2019")
|
||||||
|
check("norm_dob empty", ed._norm_dob("") == "")
|
||||||
|
|
||||||
|
# --- _LAST_DATE_RE ---
|
||||||
|
check("last date: simple", ed._LAST_DATE_RE.findall("07.04.2019") == ["07.04.2019"])
|
||||||
|
check("last date: dual-day", ed._LAST_DATE_RE.findall("16./17.03.2021") == ["17.03.2021"])
|
||||||
|
check("last date: dual-month", ed._LAST_DATE_RE.findall("31.05/*01.06.2023") == ["01.06.2023"])
|
||||||
|
|
||||||
|
# --- _is_internal ---
|
||||||
|
check("ZT is internal", ed._is_internal("ZT"))
|
||||||
|
check("BLEIBT is internal", ed._is_internal("BLEIBT"))
|
||||||
|
check("VG: is internal", ed._is_internal("VG: Partner"))
|
||||||
|
check("real name not internal", not ed._is_internal("Marion Teichmann"))
|
||||||
|
|
||||||
|
# --- LITTER_RE ---
|
||||||
|
cases = [
|
||||||
|
("D19-Wurf *07.04.2019Von: Xhemile gen. Chanel v.d. Kleinen Chaoten & Omero v.d. Kleinen Chaoten WS: 2/4Notiz:", "2/4", "07.04.2019"),
|
||||||
|
("-Wurf *16./17.03.2021Von: Victoria Welby v.d. Kleinen Chaoten & Patch v.d. Kleinen Chaoten WS: /5Notiz:", "/5", "16./17.03.2021"),
|
||||||
|
("S22-Wurf *31.05/*01.06.2023Von: Velvet v.d. Kleinen Chaoten & Vance Sohn v.d. Kleinen ChaotenWS: 3/3", "3/3", "31.05/*01.06.2023"),
|
||||||
|
("Q21-Wurf *21.03.2022Von: Belica gen. Emi v.d. Kleinen Chaoten & Zac gen. Action v.d. Kleinen ChaotenWS: 2/4Notiz:", "2/4", "21.03.2022"),
|
||||||
|
]
|
||||||
|
for para, expected_ws, _ in cases:
|
||||||
|
m = ed.LITTER_RE.search(para)
|
||||||
|
ws = m.group(5).replace(" ", "") if m else None
|
||||||
|
check(f"LITTER_RE matches: {para[:50]}...", ws == expected_ws)
|
||||||
|
|
||||||
|
if SKIP:
|
||||||
|
print("(Skipping live-docx tests: file not found)")
|
||||||
|
else:
|
||||||
|
litters, animals = ed.extract(DOCX)
|
||||||
|
check("93 litters extracted", len(litters) == 93)
|
||||||
|
check("All litters have wsCode", all(l["wsCode"] for l in litters))
|
||||||
|
check("All litters have dob", all(l["dob"] for l in litters))
|
||||||
|
check(">200 named animals", len(animals) >= 200)
|
||||||
|
check(">150 animals with owner", sum(1 for a in animals if a["owner"]) >= 150)
|
||||||
|
check(">20 animals with death date", sum(1 for a in animals if a["deathDate"]) >= 20)
|
||||||
|
# Verify first litter
|
||||||
|
d19 = next((l for l in litters if l["litterId"] == "D19-Wurf"), None)
|
||||||
|
check("D19-Wurf found", d19 is not None)
|
||||||
|
check("D19-Wurf dob correct", d19 and d19["dob"] == "07.04.2019")
|
||||||
|
check("D19-Wurf wsCode = 2/4", d19 and d19["wsCode"] == "2/4")
|
||||||
|
check("D19-Wurf mother contains Xhemile", d19 and "Xhemile" in d19["motherName"])
|
||||||
|
# Verify Eddie in animals
|
||||||
|
eddie = next((a for a in animals if a["name"] == "Eddie" and a["wsCode"] == "2/4"), None)
|
||||||
|
check("Eddie found in D19-Wurf", eddie is not None)
|
||||||
|
check("Eddie gender=male (Zobel* suffix)", eddie and eddie["gender"] == "male")
|
||||||
|
check("Eddie abgabeDate", eddie and eddie["abgabeDate"] == "12.09.2021")
|
||||||
|
# Flash death date
|
||||||
|
flash = next((a for a in animals if a["name"] == "Flash" and a["wsCode"] == "3/3"), None)
|
||||||
|
check("Flash death date extracted", flash and flash["deathDate"] == "16.09.2023")
|
||||||
|
check("Flash death cause extracted", flash and "Tumor" in flash["deathCause"])
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"\n{failed} test(s) FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nALL PASS")
|
||||||
Reference in New Issue
Block a user