Compare commits

..

32 Commits

Author SHA1 Message Date
97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav,
  site-footer-nav mit Impressum/Datenschutz-Links)
- AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung,
  Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen
  mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote)
- SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote
  erweitert
- Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als
  Platzhalter-Seiten (footer-only, kein navOrder-Eintrag)
- SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests)
- Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests
  (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier,
   Impressum/Datenschutz, Footer-Links)
Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
2026-06-06 15:15:54 +02:00
9ed68ba38a Merge feature/import-polish: decision-matching (name,zucht,dob), specific-wins dedup, parent-FK-backfill (DB-weit via allDbNormToGid), Skarlett-Parse-Fix [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:04:41 +02:00
dfcd296119 IMPORT-POLISH v2: 3 Korrekturen nach god-Review
FIX-1 ZUCHT: apply_conflict_decisions/apply_dob_remaps matchen jetzt
auf das VOLLE canon_pair-Tupel (nameCanon, zuchtCanon, dob) wenn die
Decision eine Zucht traegt; Fallback name-only wenn keine Zucht.
C3-Regel gewahrt: gleicher Name+DOB, andere Zucht -> kein Hit.
Neuer Regression-Test: Luna ZdkC-Decision trifft nur luna-kc,
nicht luna-bf (andere Zucht).

FIX-2 MERGE: dedup() waehlt das spezifischste Genotyp (fewest '?'
alleles) als sekundaeren Tiebreaker nach locus-count. CC schlaegt C-,
Gg schlaegt G- unabhaengig von der Reihenfolge. 3 neue Merge-Tests
(C- first/CC first/G-vsGg).

FIX-3 BACKFILL allDbNormToGid: ResolveParentForBackfill prueft jetzt
BEIDE Quellen: (a) createdAnimalByName (aktiver Lauf) und
(b) allDbNormToGid (alle DB-Tiere). Decktt den kritischen Fall:
Elterntier in fruehrem Lauf geladen, in diesem Lauf absent vom
Extract. Neuer 3-Lauf-SQLite-Test: Lauf 1 null-Vater, Lauf 2 laedt
Vater, Lauf 3 backfillt via allDbNormToGid.

Gate: 125/125 C#-Tests, Python ALL PASS, has-pending-model-changes=No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:03:17 +02:00
db85a6e0dc docs(HUMANQUESTION): C9 beantwortet - 'hell' im Farbschlag-Namen = c[h]-Allel (cchm/ch), ohne = cchm/cchm; CP-Fuchs ist Sammelbegriff (Phaenotyp schneeweiss/schwarzaeugig, Loci unbekannt) -> GEN-3g beauftragt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 10s
2026-06-06 14:53:39 +02:00
0c94cfcbf1 IMPORT-POLISH: 4 Importer-Fixes nach Re-Import #2
FIX-1 decision-matching: apply_conflict_decisions/apply_dob_remaps
nutzen jetzt canon_pair(name)[0] als Match-Key (Dedup-Identitaet:
call-name ohne Zucht, v.d.<->von den gefaltet). Workaround-Spelling
v.d. in Victoria Welbys Decision bleibt erhalten; beide Formen
matchen jetzt. Kommentar im decision-Eintrag aktualisiert.

FIX-2 specific-wins: _alleles_compatible aendert '? vs x = False'
-> '? vs x = True' (spezifischer Wert gewinnt). C- vs CC, G- vs Gg,
P? vs PP sind kein Konflikt mehr. Echte Wert-Widersprueche (DD vs Dd,
Ee vs ee, PP vs Pp) bleiben Konflikte. Loest Enya, Ella, Zac
automatisch (Konflikte 8->5 erwartet). 2 bestehende Tests angepasst,
7 neue Tests.

FIX-3 parent-FK backfill: nach dem Wurfchronik-Rueckverknuepfungs-
Block iteriert ImportService.RunAsync ueber bereits importierte
Wuerfe mit null Father/MotherId und setzt fehlende FKs wenn das
Elterntier jetzt ladbar ist. Trockenlauf zaehlt, Execute schreibt.
LitterSummary.ParentFksBackfilled + 2 neue C#-Tests (SQLite).

FIX-4 Skarlett-Artefakt: parse_detail() strippt trailing / +YEAR
aus dem Genotyp-Tail (re.sub). Sterbejahr bleibt als death-Date
erhalten -> Skarlett erscheint als reiner Sterbedatum-Konflikt.
2 neue Python-Tests.

Gate: 124/124 C#-Tests, Python test_extract/test_genotype ALL PASS,
has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:27 +02:00
5eadd89bb6 REIMPORT-2: Victoria-Welby-Entscheidung greift (v.d.-Schreibweise als Workaround fuer norm_name-Matching), Re-Import #2 ausgefuehrt (+13 Tiere/+31 Wuerfe/+9 Fotos, C hat beide Eltern); HUMANQUESTION D6 = letzte 5 offene Konflikte
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 2m27s
2026-06-06 14:31:14 +02:00
f9a68deb7a Merge feature/web-3: lokale Webseiten-Vorschau (GET /api/preview/{**path} + /webseite/vorschau iframe-Vorschau mit Geraete-Umschalter) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m51s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 14:22:31 +02:00
a693095886 ... 2026-06-06 13:38:03 +02:00
d5544412bd WEB-3: lokale Webseiten-Vorschau - GET /api/preview/{**path} (rendert live, korrekte Content-Types, nur Veroeffentlichte) + /webseite/vorschau (iframe, Seiten-Wechsler, Smartphone/Desktop-Umschalter, Neu laden) + Einstiege auf der Uebersicht + Fix: pages.ts rief /pages statt /api/pages (live-404, vom Mock kaschiert) + 1 Backend-Roundtrip + 4 e2e-Specs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:18:07 +02:00
d93e8d1586 Merge feature/gerbil-residency-toggle: residency checkbox on Gerbil create/edit form [god-QA pending] 2026-06-06 12:15:20 +02:00
114bbd92c8 Merge feature/extract-band-aware (presence-wins dedup + correctDob): conflicts 19→15 [god-QA pending] 2026-06-06 12:15:01 +02:00
a8d8ae0dfc conflict-decisions: correctDob remaps a wrong-birthdate duplicate before dedup
god added a `correctDob` (DD.MM.YYYY) decisions field: the matched (name+dob)
record is a DUPLICATE with a wrong birthdate → remap its DOB to correctDob so
dedup MERGES it into the canonical same-named animal. apply_dob_remaps runs
BEFORE dedup (it changes the dedup identity); tolerates a missing file; logged
as "DOB-Remaps: N". First use: Chelsea *15.10.2021 → *02.04.2021 (merges into
the canonical record). test_extract covers the remap + that both records then
share one name+dob identity.

Extractor-only. python + dotnet 121/121 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:14:23 +02:00
522f2eec51 docs(HUMANQUESTION): C8 answered — Himalaya exists (A- c[h]c[h]); Hermelin = aa c[h]c[h]; both kept 2026-06-06 12:13:20 +02:00
3f71d8e28e dedup: 'presence wins' — present-vs-absent token is not a conflict (Julian)
Breeder merge rule: when two source variants of the SAME animal differ ONLY
by a token PRESENT in one and ABSENT in the other — a whole locus (e.g. spsp
charted in one source, omitted in another) or a modifier on the same base
allele (e^f vs e, the [f] marker) — keep the present token; that is NOT a
conflict. Genuine VALUE contradictions still quarantine: different base
alleles (Ee↔ee), unknown-vs-filled (D-↔DD), different modifiers (c[h]↔c[chm]),
C-↔Cc[h], P-↔Pp.

Replaces the old `len(distinct normalized geno keys) > 1` test with
_genotype_conflict() (per-locus, per-allele compatibility; '?'-vs-filled is a
contradiction, modifier-present-vs-absent and whole-locus-absence are not).
Markers/flags (WP/DP/WFNZ/hörend) are already tags/flags, never genotype, so
they never reach conflict detection; empty Farbschlag/death already don't
conflict (only non-empty values are compared).

Clears Daja (keep spsp), Ichika (keep ee[f]) and the D4 marker cases:
Konflikte 19 -> 15. test_extract covers spsp/[f] present-vs-absent =
no conflict and the four genuine-contradiction shapes. python + dotnet
121/121 green; extractor-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:45 +02:00
0e7ec5ab61 BESTAND-FILTER follow-up: residency toggle on the Gerbil create/edit form
Completes residency end-to-end: a 'Gehört zum eigenen Bestand' checkbox on the
Rennmaus-Formular (default on for new animals) lets the user mark a tier as an
external ancestor by hand — previously isResident was only set at import. Wired
through FormState + formFromGerbil (defaults true) + the CreateGerbil payload.

German via de.ts (LF). e2e: edit Krümel, untick Bestand, save -> detail shows
the 'Extern' badge (proves form field -> payload -> persistence). Gate green:
build, eslint, vitest 78, e2e 86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:32 +02:00
12604fba7c data(import): D4 Victoria Welby resolved (ee[f]) — C's mother → C gets both parents — Julian 2026-06-06 12:12:23 +02:00
311c5461fd data(import): Chelsea (merge to *02.04.2021, correctDob) + Vestra (DD) resolved — Julian 2026-06-06 12:11:41 +02:00
3634d6ef9a data(import): D3 Silvain resolved (ee, Pp) — Julian 2026-06-06 12:09:41 +02:00
aa345aa796 data(import): D3 Milka resolved (Cc[h], EE) — Julian 2026-06-06 12:08:14 +02:00
0317c5d454 Merge feature/extract-band-aware: band-aware Farbschlag + conflict-decisions consumption + GEN-3f backend re-seed (ReseedColorVarietiesGen3f) [god-QA pending] 2026-06-06 12:05:02 +02:00
ea8c3e9966 Merge feature/bestand-filter: Tiere list defaults nur-Bestand + toggle + Extern marker [god-QA pending] 2026-06-06 12:05:02 +02:00
4d3383a4f1 data(import): D3 Zuleika resolved (DD Ee Gg PP) — Julian 2026-06-06 12:04:30 +02:00
19a0a6c79b data(import): 'presence wins' merge rule (Julian) — resolves Daja (spsp) + Ichika ([f]); D4 markers were common, 3/5 auto-resolved 2026-06-06 12:02:29 +02:00
b9033238c9 conflict-decisions: also apply dateOfDeath (D5 death-date resolutions)
god extended conflict-decisions.json with an optional dateOfDeath
(DD.MM.YYYY). apply_conflict_decisions now sets the animal's death date
(normalized) as authoritative when present — clearing D5 death-date
conflicts the same way genotype/farbschlag decisions are applied. No C#
change (death already flows to Gerbil.DateOfDeath). test_extract covers a
Flint dateOfDeath resolution. python green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:02:18 +02:00
594923e10f GEN-3f backend re-seed: ColorVariety catalog 73 -> 61 (CP- names)
Re-synced SeedColorVarieties from Kevin's merged GEN-3f frontend seed
(colorVarietySeed.generated.json, 61 rows) — verified byte-identical
(name + canonicalGenotype + order). Changes are all in the cchm colourpoint
group: 'Siam (Marder-Hell)' -> 'Siam' (het cchmch), CP suffix names ->
CP- prefix (CP-Agouti/CP-Silberagouti/CP-Algierfuchs/CP-Polarfuchs), new
CP-Fuchs/CP-Fuchs-Hell/CP-Blaufuchs, Zobel-Hell kept (het); 12 zero-
occurrence CP/dd siblings removed.

Migration ReseedColorVarietiesGen3f: Up = 38 UpdateData (rename/remap IN
PLACE on the same deterministic Id — references survive) + 12 DeleteData of
the removed rows (0 occurrences in her data per Kevin; Gerbil.ColorVarietyId
is ON DELETE SET NULL, and the live re-import re-matches Farbschlag BY NAME,
so no orphan/hard-delete risk). No AddColumn. has-pending-model-changes
clean. Updated the export test's seed-count threshold (>=70 -> >=60).

Last of the import-side batch on this branch (band-aware Farbschlag +
conflict-decisions + this re-seed) — ready for the single supervised
re-extract + idempotent re-import. dotnet 121/121 + python green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:59:57 +02:00
aa20d6f809 data(import): D5 Molly resolved (died 03.05.2022, 2021 was year typo) — Julian 2026-06-06 11:59:51 +02:00
afd321ec9f BESTAND-FILTER: Tiere-Liste zeigt standardmäßig nur den eigenen Bestand
Die Tiere-Liste (/rennmaeuse) filtert serverseitig auf isResident==true
(eigener Clan = Bestand) und blendet externe Ahnen (nur für den Stammbaum
erfasste Fremdtiere) standardmäßig aus — passend zu Julians Modell
(Kleine Chaoten = Bestand, extern = stammbaum-only).

- Neuer Schalter „Externe Ahnen einblenden“ (Default aus). An -> isResident-
  Filter entfällt, externe Tiere erscheinen mit. Serverseitig (Gridify), damit
  Paging und Trefferzahl korrekt bleiben. „Filter zurücksetzen“ setzt ihn zurück.
- Subtile „Extern“-Markierung (gestricheltes Badge) auf externen Tieren in
  Liste UND Detailseite, mit Tooltip-Erläuterung.
- Gerbil-Typ um isResident (optional bool) ergänzt; CreateGerbil ebenso.

Deutsch via de.ts (LF), mobil + Laptop. e2e-Mock: gerbil()-Factory setzt
isResident=true; Hilde & Max als externe Ahnen (isResident=false). Neue
e2e bestand.spec (Default blendet aus; Toggle zeigt + Extern-Markierung).
Gate grün: build, eslint, vitest 78, e2e 84.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:59:19 +02:00
ae825ae80a data(import): D5 Flint resolved (died 10.05.2021, 2022 was typo) + decisions schema gains dateOfDeath — Julian 2026-06-06 11:59:10 +02:00
e055061d85 docs(HUMANQUESTION): D3+D4 — show CONCRETE per-animal conflict (locus + both values); D4 markers were common (not the conflict), 3/5 auto-resolved; Chelsea=DOB-dup not genotype; Victoria Welby=C's mother 2026-06-06 11:57:54 +02:00
df13136955 Importer consumes conflict-decisions.json to un-quarantine (HUMANQUESTION D)
god maintains tools/import/conflict-decisions.json as Julian/his wife answer
the D-conflicts. extract.py now consumes it (apply_conflict_decisions): for an
animal matching normalize(name)+dob, it clears the conflict, marks
resolvedByDecision, and — when the decision carries a `genotype` (breeder
notation, parsed via genotype.py) and/or `farbschlag` — treats those as
AUTHORITATIVE. Tolerates a missing/empty/garbled file. Genuinely-unresolved
conflicts stay quarantined.

Loader (ImportService): SourceAnimal.ResolvedByDecision flows through; the
report surfaces Animals.ConflictsResolvedByDecision + a German note.

Result on real data: the 2 current decisions (Firefly D-/PP, WildFire PP)
un-quarantine → Konflikte 21 → 19. As god appends entries the count grows;
nothing else needed from me.

Tests: python test_extract (decision clears conflict + genotype authoritative
+ removes from conflicts list + tolerates missing file) and a C# loader test
(a resolved animal loads and is counted). Folded into the EXTRACT-BANDS branch
so the next re-extract applies band-aware Farbschlag + these decisions in one
pass. No schema change (JSON DTO fields). python + dotnet 121/121 green;
has-pending clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:56:29 +02:00
4aca1d528b EXTRACT band-aware Farbschlag: deep bands (gen>=2) have no colour cell
Julian-confirmed against the real xlsx (Kentucky, Akio Kids): pedigree
blocks have two shapes. EARLY bands (gen 0-1, cols B/E/H) are 5-cell
Name/DOB/Farbschlag/Genotype/Zucht — real Farbschlag present. DEEP bands
(gen>=2, cols K/N/Q...) are 3-cell Name/DOB/Genotype with NO Farbschlag —
the colour is derived from the genotype.

The old extractor grabbed the NEXT block's name or a stray health note
(e.g. "DD-Tumor", "Chronische Ohrenentzündung...") as the deep-band
Farbschlag — source of the D1 'name-in-Farbschlag' conflicts and a big
chunk of farbschlagUnmatched. Fix: gen_of(col)>=2 => never assign a
Farbschlag in that block (Name/DOB/Genotype only); the cell is left for the
next block's name search. Deep-band animals load with empty Farbschlag and
the UI computes 'Errechnet' from the stored genotype (lossless).

Dedup already prefers a real (non-empty) early-band Farbschlag, so an animal
appearing in both an early band (Chesnut -> 'Kohlfuchsschimmel') and deep
bands keeps the real value; Chesnut and Tennessee stay separate (distinct
genotype + name+DOB key).

Result: 248/306 loadable animals now have empty Farbschlag (genotype-
derived), 0 suspicious name/note values remain, 0 Farbschlag-based conflicts
(D1 dissolved). Regression test test_extract.py builds a two-band xlsx and
asserts the deep band yields no Farbschlag + the stray note doesn't bleed.

Extractor-only (no C# change). python test_genotype + test_extract green;
dotnet 118/118. Batches with Kevin's GEN-3f seed for one re-import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:56:29 +02:00
4e02133d95 docs(HUMANQUESTION): add C9 (optional) — CP-Fuchs/CP-Blaufuchs/CP-Fuchs-Hell genetic distinction for engine refinement 2026-06-06 11:53:14 +02:00
38 changed files with 5234 additions and 401 deletions

View File

@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
/// styles.css kommt mit CSS-Content-Type.
/// </summary>
public class CmsPreviewTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
private static object PageInput(string slug, string title, string status) =>
new { slug, title, seoDescription = (string?)null, status };
private sealed record PageRow(Guid Id, string Slug);
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
{
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
.FirstOrDefault(p => p.Slug == slug);
if (existing is null)
{
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
else
{
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
}
}
[Fact]
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
{
var client = _factory.CreateClient();
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
var index = await client.GetAsync("/api/preview/");
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
// Expliziter Pfad funktioniert ebenso
var explicitIndex = await client.GetAsync("/api/preview/index.html");
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
var css = await client.GetAsync("/api/preview/assets/site.css");
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
// Unsinnige Pfade → 404
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
}
}

View File

@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
public CmsTests(ApiFactory factory) => _client = factory.CreateClient(); public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact] [Fact]
public async Task Snapshot_has_site_nav_and_six_seeded_pages() public async Task Snapshot_has_site_nav_and_seeded_pages()
{ {
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot")); var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var root = doc.RootElement; var root = doc.RootElement;
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString()); Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList(); var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav); Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
var pages = root.GetProperty("pages").EnumerateArray().ToList(); var pages = root.GetProperty("pages").EnumerateArray().ToList();
Assert.Equal(6, pages.Count); // 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
Assert.Equal(8, pages.Count);
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array // abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere"); var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
var listBlock = abg.GetProperty("blocks").EnumerateArray() var listBlock = abg.GetProperty("blocks").EnumerateArray()

View File

@@ -104,7 +104,7 @@ namespace GerbilManager.Tests
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength()); Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
Assert.Equal(1, root.GetProperty("litters").GetArrayLength()); Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength()); Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
var g = root.GetProperty("gerbils")[0]; var g = root.GetProperty("gerbils")[0];
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString()); Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString()); Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());

View File

@@ -278,6 +278,33 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } } finally { try { Directory.Delete(dir, recursive: true); } catch { } }
} }
[Fact]
public async Task Decision_resolved_animal_loads_and_is_counted()
{
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
// un-quarantines an animal; the loader must then LOAD it and surface the count.
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
"conflict":false,"resolvedByDecision":true}
]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact] [Fact]
public void ComposeGenotype_strips_carets_and_fills_missing_loci() public void ComposeGenotype_strips_carets_and_fills_missing_loci()
{ {
@@ -294,6 +321,192 @@ namespace GerbilManager.Tests
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g)); Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
} }
[Fact]
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
{
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
// (conflict=false — loaded). After run 1: litter.FatherId = null.
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
var littersJson = """
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
""";
// Run 1: Vater is in conflict -> not loaded
var animals1 = """
[
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
]
""";
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
var animals2 = """
[
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
]
""";
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Fact]
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
{
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
//
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
// Run 2: sire loaded (new animal).
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
// Backfill must use allDbNormToGid to find him.
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
var littersJson = """
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
""";
// Run 1: sire quarantined
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
// Run 2: sire now loaded
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
]
""");
await new ImportService(db, dir, dir).RunAsync(execute: true);
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
// Run 2 itself may or may not backfill (depends on name normalization alignment).
// For the test we care about run 3.
// Run 3: sire NOT in animals.json at all (absent from new extract).
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
// by manually resetting FatherId to null so run 3 must fix it.
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
litter3.FatherId = null;
await db.SaveChangesAsync();
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
]
""");
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Fact]
public async Task ParentFkBackfill_dry_run_counts_without_writing()
{
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
var littersJson = """
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
""";
var animals1 = """
[
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
]
""";
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
// Run 2 dry-run with papi un-quarantined
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
Assert.Null(litter.FatherId); // not written in dry-run
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Theory] [Theory]
[InlineData("01.02.2020", 2020, 2, 1)] [InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)] [InlineData("5.3.21", 2021, 3, 5)]

View File

@@ -69,10 +69,11 @@ public class SiteRendererTests
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone }, ["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
}; };
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals) private static JsonObject AbgabetiereBlock(string intro,
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
{ {
var animalArr = new JsonArray(); var animalArr = new JsonArray();
foreach (var (name, farbe, group, photo) in animals) foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
{ {
var photos = new JsonArray(); var photos = new JsonArray();
if (photo is not null) photos.Add(photo); if (photo is not null) photos.Add(photo);
@@ -81,6 +82,9 @@ public class SiteRendererTests
["name"] = name, ["name"] = name,
["farbschlag"] = farbe, ["farbschlag"] = farbe,
["group"] = group, ["group"] = group,
["gender"] = gender,
["dateOfBirth"] = dob,
["characterNote"] = note,
["photos"] = photos, ["photos"] = photos,
["aiSaleText"] = (JsonNode?)null, ["aiSaleText"] = (JsonNode?)null,
}); });
@@ -92,6 +96,7 @@ public class SiteRendererTests
}; };
} }
// ── Tests ──────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -211,16 +216,52 @@ public class SiteRendererTests
} }
[Fact] [Fact]
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag() public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
{ {
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg"))))); Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Krümel", html); Assert.Contains("Krümel", html);
Assert.Contains("CP-Agouti", html); Assert.Contains("CP-Agouti", html);
Assert.Contains("Großbecken", html); Assert.Contains("Großbecken", html);
Assert.Contains("/photos/files/abc.jpg", html); Assert.Contains("/photos/files/abc.jpg", html);
Assert.Contains("cms-animal-card", html); Assert.Contains("cms-group-card", html);
}
[Fact]
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Weibchen", html);
Assert.Contains("Verfügbar", html);
Assert.Contains("cms-badge--free", html);
}
[Fact]
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("März 2024", html);
Assert.Contains("Sehr neugierig und verspielt.", html);
}
[Fact]
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Einzelkind", "Zobel", null, null, "male", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Einzelkind", html);
Assert.Contains("Männchen", html);
Assert.Contains("cms-group-card", html);
} }
[Fact] [Fact]
@@ -283,6 +324,44 @@ public class SiteRendererTests
Assert.Equal(7, files.Count); Assert.Equal(7, files.Count);
} }
[Fact]
public void Render_impressum_and_datenschutz_pages_are_rendered()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("impressum/index.html"));
Assert.True(files.ContainsKey("datenschutz/index.html"));
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
}
[Fact]
public void Footer_contains_impressum_and_datenschutz_links()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var startHtml = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("impressum/index.html", startHtml);
Assert.Contains("datenschutz/index.html", startHtml);
// links in footer, not nav
Assert.Contains("site-footer-link", startHtml);
}
[Fact]
public void Footer_links_use_correct_relative_path_from_subpage()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
// subpage needs ../ prefix for footer links
Assert.Contains("../impressum/index.html", kontaktHtml);
Assert.Contains("../datenschutz/index.html", kontaktHtml);
}
// ── Markdown tests ─────────────────────────────────────────────────────── // ── Markdown tests ───────────────────────────────────────────────────────
[Theory] [Theory]

View File

@@ -193,6 +193,15 @@ public class ApplicationContext : DbContext
(6, "kontakt", "Kontakt"), (6, "kontakt", "Kontakt"),
}; };
// WEB-6a: footer-only legal pages (not in main nav)
(int n, string Slug, string Title, string BodyMd)[] legalPages =
{
(7, "impressum", "Impressum",
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
(8, "datenschutz", "Datenschutz",
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
};
var pageRows = new List<Page>(); var pageRows = new List<Page>();
var blockRows = new List<Block>(); var blockRows = new List<Block>();
foreach (var p in pages) foreach (var p in pages)
@@ -211,6 +220,12 @@ public class ApplicationContext : DbContext
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList, Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
Data = "{\"mode\":\"auto\",\"intro\":\"\"}", Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
}); });
foreach (var lp in legalPages)
{
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
}
modelBuilder.Entity<Page>().HasData(pageRows); modelBuilder.Entity<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows); modelBuilder.Entity<Block>().HasData(blockRows);
@@ -256,8 +271,6 @@ public class ApplicationContext : DbContext
("C-Separator", "aa CC DD ee gg pp spsp rere"), ("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere"), ("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"), ("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"), ("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"), ("Saphir", "aa CC DD EE GG pp spsp rere"),
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"), ("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
@@ -267,44 +280,34 @@ public class ApplicationContext : DbContext
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"), ("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"), ("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"), ("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"), ("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"), ("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"), ("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Polarfuchs-Hell CP", "AA cchmcchm DD ee gg PP spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"), ("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"), ("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"), ("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"), ("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"), ("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere"), ("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Agouti-Hell dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"), ("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"), ("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"), ("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"), ("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"), ("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"), ("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
("Polarfuchs CP", "AA cchmcchm DD ee gg PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"), ("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "AA CC dd EE GG pp spsp rere"), ("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"), ("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"), ("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
}; };

View File

@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
private static string RenderAbgabetiereList(JsonObject d) private static string RenderAbgabetiereList(JsonObject d)
{ {
var intro = Str(d["intro"]); var intro = Str(d["intro"]);
var animals = d["animals"] as JsonArray ?? []; var animals = (d["animals"] as JsonArray ?? [])
.OfType<JsonObject>().ToList();
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n"); var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
if (!string.IsNullOrWhiteSpace(intro)) if (!string.IsNullOrWhiteSpace(intro))
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
if (animals.Count == 0) if (animals.Count == 0)
{ {
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n"); sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
sb.Append("</section>\n");
return sb.ToString();
} }
else
// Group by Becken name; null group → solo card keyed by animal name
var groups = animals
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
.ToList();
sb.Append(" <div class=\"cms-group-list\">\n");
foreach (var grp in groups)
{ {
sb.Append(" <div class=\"cms-animal-grid\">\n"); var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
foreach (var node in animals) var groupName = isSolo ? null : grp.Key;
var memberList = grp.ToList();
// Determine group gender label
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
string genderLabel = genders.Count == 1
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
sb.Append(" <article class=\"cms-group-card\">\n");
sb.Append(" <div class=\"cms-group-header\">\n");
if (!string.IsNullOrEmpty(groupName))
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
var metaParts = new List<string>();
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
sb.Append(" </div>\n");
// Gallery: first photo of each animal in the group
var gallery = memberList
.Select(a => (
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
name: Str(a["name"]) ?? ""))
.Where(x => !string.IsNullOrEmpty(x.url))
.ToList();
if (gallery.Count > 0)
{ {
if (node is not JsonObject a) continue; sb.Append(" <div class=\"cms-group-gallery\">\n");
var name = H(Str(a["name"]) ?? ""); foreach (var (url, name) in gallery)
var farbe = H(Str(a["farbschlag"]) ?? ""); sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
var group = Str(a["group"]);
var saleText = Str(a["aiSaleText"]);
var photos = a["photos"] as JsonArray ?? [];
sb.Append(" <article class=\"cms-animal-card\">\n");
// Profile photo (first photo)
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
if (!string.IsNullOrEmpty(firstPhoto))
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
sb.Append(" <div class=\"cms-animal-info\">\n");
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
if (!string.IsNullOrEmpty(farbe))
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
if (!string.IsNullOrWhiteSpace(group))
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
if (!string.IsNullOrWhiteSpace(saleText))
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
sb.Append(" </div>\n"); sb.Append(" </div>\n");
sb.Append(" </article>\n");
} }
sb.Append(" </div>\n");
// Per-animal detail rows
sb.Append(" <ul class=\"cms-animal-list\">\n");
foreach (var a in memberList)
{
var name = H(Str(a["name"]) ?? "");
var farbe = Str(a["farbschlag"]);
var dob = Str(a["dateOfBirth"]);
var note = Str(a["characterNote"]);
var saleText = Str(a["aiSaleText"]);
sb.Append(" <li class=\"cms-animal-row\">\n");
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
var details = new List<string>();
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
if (details.Count > 0)
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
var personalityText = note ?? saleText;
if (!string.IsNullOrWhiteSpace(personalityText))
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
sb.Append(" </li>\n");
}
sb.Append(" </ul>\n");
sb.Append(" </article>\n");
} }
sb.Append(" </div>\n");
sb.Append("</section>\n"); sb.Append("</section>\n");
return sb.ToString(); return sb.ToString();
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<p>&copy; {H(SiteName)} Mongolische Rennmäuse</p> <p>&copy; {H(SiteName)} Mongolische Rennmäuse</p>
<nav class="site-footer-nav" aria-label="Rechtliches">
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
</nav>
</footer> </footer>
</body> </body>
</html> </html>
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
["abgabebedingungen"] = "Abgabebedingungen", ["abgabebedingungen"] = "Abgabebedingungen",
["farben-genetik"] = "Farben & Genetik", ["farben-genetik"] = "Farben & Genetik",
["kontakt"] = "Kontakt", ["kontakt"] = "Kontakt",
["impressum"] = "Impressum",
["datenschutz"] = "Datenschutz",
}; };
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
internal static string SiteCss() => """ internal static string SiteCss() => """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--color-bg: #fdf8f3; --color-bg: #fdf7f0;
--color-surface: #fff; --color-surface: #fff;
--color-text: #2c2c2c; --color-surface-warm: #fef9f4;
--color-muted: #6b6b6b; --color-text: #2a2118;
--color-accent: #c0392b; --color-muted: #7a6d62;
--color-border: #e0d8d0; --color-accent: #b5331a;
--font-body: system-ui, sans-serif; --color-accent-hover: #8f2815;
--max-w: 860px; --color-accent-soft: #fdf0ed;
--color-border: #e8ddd4;
--color-badge-free: #2d7a4a;
--color-badge-free-bg: #e6f4ec;
--font-body: Georgia, "Times New Roman", serif;
--font-ui: system-ui, -apple-system, sans-serif;
--max-w: 900px;
--radius: 10px;
} }
body { body {
font-family: var(--font-body); font-family: var(--font-body);
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
line-height: 1.65; line-height: 1.7;
font-size: 1rem;
} }
a { color: var(--color-accent); text-decoration: none; } a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; color: var(--color-accent-hover); }
img { max-width: 100%; height: auto; display: block; } img { max-width: 100%; height: auto; display: block; }
p { margin-bottom: .75rem; }
/* ── Header ── */ /* ── Header ── */
.site-header { .site-header {
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-bottom: 2px solid var(--color-border);
padding: .75rem 1rem; padding: 0 1rem;
display: flex; display: flex;
flex-wrap: wrap; align-items: stretch;
align-items: center; gap: 0;
gap: .5rem 1.5rem; min-height: 56px;
} }
.site-logo { .site-logo {
font-family: var(--font-body);
font-weight: 700; font-weight: 700;
font-size: 1.1rem; font-size: 1.15rem;
color: var(--color-text); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
} display: flex;
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; } align-items: center;
.site-nav-link { padding-right: 1.5rem;
font-size: .9rem; border-right: 1px solid var(--color-border);
color: var(--color-muted);
padding: .2rem .4rem;
border-radius: 4px;
}
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: #fef0ee;
text-decoration: none; text-decoration: none;
flex-shrink: 0;
}
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
.site-nav {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-left: .5rem;
}
.site-nav::-webkit-scrollbar { display: none; }
.site-nav-link {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
padding: .5rem .65rem;
border-radius: 6px;
white-space: nowrap;
transition: color .15s, background .15s;
}
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
.site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: var(--color-accent-soft);
font-weight: 600;
} }
/* ── Main ── */ /* ── Main ── */
.site-main { .site-main {
max-width: var(--max-w); max-width: var(--max-w);
margin: 2rem auto; margin: 2.5rem auto;
padding: 0 1rem 3rem; padding: 0 1rem 4rem;
} }
/* ── Footer ── */ /* ── Footer ── */
.site-footer { .site-footer {
border-top: 1px solid var(--color-border); background: var(--color-surface);
padding: 1.5rem 1rem; border-top: 2px solid var(--color-border);
padding: 1.75rem 1rem;
text-align: center; text-align: center;
font-family: var(--font-ui);
font-size: .85rem; font-size: .85rem;
color: var(--color-muted); color: var(--color-muted);
} }
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
.site-footer-link:hover { color: var(--color-accent); }
/* ── CMS blocks ── */ /* ── CMS blocks ── */
.cms-heading { margin: 1.5rem 0 .5rem; } .cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
h1.cms-heading { font-size: 1.8rem; } h1.cms-heading { font-size: 2rem; margin-top: 0; }
h2.cms-heading { font-size: 1.4rem; } h2.cms-heading { font-size: 1.5rem; }
h3.cms-heading { font-size: 1.2rem; }
.cms-richtext { margin: 1rem 0; } .cms-richtext { margin: 1rem 0; }
.cms-richtext p { margin-bottom: .75rem; } .cms-richtext p { margin-bottom: .75rem; }
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; } .cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
.cms-richtext li { margin-bottom: .3rem; } .cms-richtext li { margin-bottom: .35rem; }
.cms-image { margin: 1.5rem 0; } .cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
.cms-image img { border-radius: 6px; } .cms-image { margin: 1.75rem 0; }
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.cms-gallery { .cms-gallery {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem; gap: .75rem;
margin: 1.5rem 0; margin: 1.75rem 0;
} }
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; } .cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
.cms-contact { .cms-contact {
font-style: normal; font-style: normal;
font-family: var(--font-ui);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .4rem; gap: .5rem;
margin: 1rem 0; margin: 1.25rem 0;
background: var(--color-surface-warm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
} }
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; } .cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
/* ── Abgabetiere ── */ /* ── Abgabetiere — group cards ── */
.cms-abgabe { margin: 1rem 0; } .cms-abgabe { margin: 1.25rem 0; }
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; } .cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; } .cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-animal-grid {
display: grid; .cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem; .cms-group-card {
}
.cms-animal-card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius);
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.cms-group-header {
padding: 1rem 1.25rem .75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem .75rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-warm);
}
.cms-group-name {
font-family: var(--font-body);
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
}
.cms-group-meta {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
display: flex;
align-items: center;
gap: .5rem;
} }
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
.cms-animal-info { padding: .875rem; }
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
@media (max-width: 480px) { /* availability badge */
h1.cms-heading { font-size: 1.4rem; } .cms-badge {
.cms-animal-grid { grid-template-columns: 1fr; } display: inline-block;
font-size: .75rem;
font-weight: 600;
font-family: var(--font-ui);
padding: .2rem .55rem;
border-radius: 20px;
letter-spacing: .02em;
text-transform: uppercase;
}
.cms-badge--free {
background: var(--color-badge-free-bg);
color: var(--color-badge-free);
}
/* group photo strip */
.cms-group-gallery {
display: flex;
gap: .375rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: .75rem 1.25rem;
background: #f5ede4;
}
.cms-group-gallery::-webkit-scrollbar { display: none; }
.cms-group-photo {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
/* animal detail list */
.cms-animal-list {
list-style: none;
padding: .75rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: .875rem;
}
.cms-animal-row {
display: flex;
flex-direction: column;
gap: .15rem;
padding-bottom: .875rem;
border-bottom: 1px solid var(--color-border);
}
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
.cms-animal-name {
font-family: var(--font-body);
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.cms-animal-detail {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
}
.cms-animal-note {
font-style: italic;
font-size: .925rem;
color: var(--color-text);
margin-top: .1rem;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.site-header { padding: 0 .75rem; min-height: 50px; }
.site-logo { font-size: 1rem; padding-right: 1rem; }
h1.cms-heading { font-size: 1.5rem; }
.site-main { margin-top: 1.5rem; }
.cms-group-photo { width: 110px; height: 110px; }
}
@media (min-width: 700px) {
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
} }
"""; """;

View File

@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
{ {
g.Id, g.Id,
g.Name, g.Name,
g.Gender,
g.DateOfBirth,
g.CharacterNote,
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null, Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
// group = Becken (enclosure) name — matches how the Abgabe composer // group = Becken (enclosure) name — matches how the Abgabe composer
// groups ForSale animals by enclosure-mates (god ruling). null = single. // groups ForSale animals by enclosure-mates (god ruling). null = single.
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
arr.Add(new JsonObject arr.Add(new JsonObject
{ {
["name"] = a.Name, ["name"] = a.Name,
["gender"] = a.Gender.ToString(),
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
["characterNote"] = a.CharacterNote,
["farbschlag"] = a.Farbschlag, ["farbschlag"] = a.Farbschlag,
["group"] = a.Group, ["group"] = a.Group,
["photos"] = photoArr, ["photos"] = photoArr,

View File

@@ -32,6 +32,24 @@ 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-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
if (!files.TryGetValue(key, out var content) &&
!files.TryGetValue($"{key}/index.html", out content))
{
return Results.NotFound();
}
return Results.Content(content, PreviewContentType(key));
});
// ---- pages ---- // ---- pages ----
api.MapGet("/pages", async (ApplicationContext db) => api.MapGet("/pages", async (ApplicationContext db) =>
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug) TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
@@ -151,6 +169,13 @@ namespace GerbilManagerWebAPI.Endpoints
return app; return app;
} }
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
: "text/html; charset=utf-8";
private static BlockDto ToBlockDto(Block b) => private static BlockDto ToBlockDto(Block b) =>
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data)); new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));

View File

@@ -27,6 +27,10 @@ namespace GerbilManagerWebAPI.Import
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype. // provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
public bool? Deaf { get; set; } public bool? Deaf { get; set; }
public List<string> Tags { get; set; } = new(); public List<string> Tags { get; set; } = new();
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
// this animal (its genotype/farbschlag are then authoritative). For reporting.
public bool ResolvedByDecision { get; set; }
} }
public sealed class SourceGenotype public sealed class SourceGenotype
@@ -82,7 +86,8 @@ namespace GerbilManagerWebAPI.Import
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule); public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0); int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
int ParentFksBackfilled = 0);
public sealed record AnimalSummary( public sealed record AnimalSummary(
int InSource, int InSource,
@@ -92,7 +97,8 @@ namespace GerbilManagerWebAPI.Import
int FarbschlagUnmatched, int FarbschlagUnmatched,
int AlreadyImported, int AlreadyImported,
QuarantineSummary Quarantined, QuarantineSummary Quarantined,
int ParentLinksFromChart = 0); int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0);
public sealed record QuarantineSummary( public sealed record QuarantineSummary(
int Conflicts, int Conflicts,

View File

@@ -399,20 +399,80 @@ namespace GerbilManagerWebAPI.Import
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
// have null Father/MotherId because the parent was previously quarantined may now be
// resolvable. Two lookup sources — must check BOTH:
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
// animals absent from this run's animals.json, or name normalization mismatch
// between animals.json and the Wurfchronik sire/dam field).
// Counted for dry-run too; writes only when execute=true.
int parentFksBackfilled = 0;
{
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
var allDbNormToGid = existingRows
.GroupBy(g => Normalize(StripZucht(g.Name)))
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
var existingWithNullParent = await _db.Litters
.Where(l => l.FatherId == null || l.MotherId == null)
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
.ToListAsync();
var sourceByName = litters
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
.ToDictionary(g => g.Key, g => g.First());
Guid? ResolveParentForBackfill(string rawName)
{
var n = Normalize(StripZucht(rawName));
if (n.Length == 0) return null;
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
return fromLoadable;
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
return fromDb;
return null;
}
foreach (var el in existingWithNullParent)
{
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
if (newF is null && newM is null) continue;
parentFksBackfilled++;
if (execute)
{
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
if (row is not null)
{
if (newF is not null) row.FatherId = newF;
if (newM is not null) row.MotherId = newM;
}
}
}
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
}
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0) if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher."); notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
if (parentFksBackfilled > 0)
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt)."); notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
if (conflictsResolvedByDecision > 0)
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport( return new ImportReport(
Executed: execute, Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped), Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded), parentLinksAdded, conflictsResolvedByDecision),
Photos: new PhotoSummary(photosAttached, photosMissing), Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples, Samples: samples,
Notes: notes, Notes: notes,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,632 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesGen3f : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
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.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000071"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000072"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000073"));
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmch DD EE GG PP spsp rere", "Siam" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmch DD EE gg PP spsp rere", "Zobel-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "CP-Agouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "CP-Silberagouti" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "CP-Algierfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "CP-Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd ee GG PP spsp rere", "CP-Fuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmch dd ee GG PP spsp rere", "CP-Fuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd ee gg PP spsp rere", "CP-Blaufuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
columns: new[] { "CanonicalGenotype", "Name" },
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 61 },
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 62 },
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 63 },
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 64 },
{ new Guid("00000000-0000-0000-0000-000000000066"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 65 },
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 66 },
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 67 },
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC dd EE GG pp spsp rere", "Topas dd", 68 },
{ new Guid("00000000-0000-0000-0000-000000000070"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 69 },
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 70 },
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa CC dd ee gg pp spsp rere", "Blaufuchs dd", 71 },
{ new Guid("00000000-0000-0000-0000-000000000073"), "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel", 72 }
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddImpressumDatenschutzPages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Pages",
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
values: new object[,]
{
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
});
migrationBuilder.InsertData(
table: "Blocks",
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
values: new object[,]
{
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
}
}
}

View File

@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
Order = 1, Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000003"), PageId = new Guid("51720001-0000-0000-0000-000000000003"),
Type = "AbgabetiereList" Type = "AbgabetiereList"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000007"),
Data = "{\"text\":\"Impressum\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000070"),
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "RichText"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000008"),
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000080"),
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "RichText"
}); });
}); });
@@ -343,352 +375,268 @@ namespace GerbilManagerWebAPI.Migrations
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000024"), Id = new Guid("00000000-0000-0000-0000-000000000024"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
Name = "Marder", Name = "Polarfuchs",
SortOrder = 23 SortOrder = 23
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000025"), Id = new Guid("00000000-0000-0000-0000-000000000025"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
Name = "Siam (Marder-Hell)", Name = "Saphir",
SortOrder = 24 SortOrder = 24
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000026"), Id = new Guid("00000000-0000-0000-0000-000000000026"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere", CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Polarfuchs", Name = "Orangeschimmel",
SortOrder = 25 SortOrder = 25
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000027"), Id = new Guid("00000000-0000-0000-0000-000000000027"),
CanonicalGenotype = "aa CC DD EE GG pp spsp rere", CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Saphir", Name = "Topas",
SortOrder = 26 SortOrder = 26
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000028"), Id = new Guid("00000000-0000-0000-0000-000000000028"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere", CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
Name = "Orangeschimmel", Name = "Platin-Hell",
SortOrder = 27 SortOrder = 27
}, },
new new
{ {
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 = "Topas", Name = "Agouti dd",
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 = "Platin-Hell", Name = "Silberagouti dd",
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 = "Agouti dd", Name = "Kohlfuchs dd",
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 = "Silberagouti dd", Name = "Anthrazit dd",
SortOrder = 31 SortOrder = 31
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000033"), Id = new Guid("00000000-0000-0000-0000-000000000033"),
CanonicalGenotype = "aa CC dd ee GG PP spsp rere", CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Kohlfuchs dd", Name = "Silberschimmel",
SortOrder = 32 SortOrder = 32
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000034"), Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "aa CC dd EE gg PP spsp rere", CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Anthrazit dd", Name = "Polarfuchsschimmel",
SortOrder = 33 SortOrder = 33
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000035"), Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Agouti CP-Hell", Name = "Algierfuchsschimmel",
SortOrder = 34 SortOrder = 34
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000036"), Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Blaufuchs CP", Name = "Kohlfuchsschimmel",
SortOrder = 35 SortOrder = 35
}, },
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 = "Silberschimmel", Name = "Blaufuchsschimmel",
SortOrder = 36 SortOrder = 36
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000038"), Id = new Guid("00000000-0000-0000-0000-000000000038"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere", CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Polarfuchsschimmel", Name = "Kohlfuchs, hell",
SortOrder = 37 SortOrder = 37
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000039"), Id = new Guid("00000000-0000-0000-0000-000000000039"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere", CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
Name = "Algierfuchsschimmel", Name = "Goldfuchs, hell",
SortOrder = 38 SortOrder = 38
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000040"), Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
Name = "Polarfuchs-Hell CP", Name = "Goldfuchsschimmel",
SortOrder = 39 SortOrder = 39
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000041"), Id = new Guid("00000000-0000-0000-0000-000000000041"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere", CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Kohlfuchsschimmel", Name = "Gold-Hell",
SortOrder = 40 SortOrder = 40
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000042"), Id = new Guid("00000000-0000-0000-0000-000000000042"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere", CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
Name = "Blaufuchsschimmel", Name = "Blaufuchs, hell",
SortOrder = 41 SortOrder = 41
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000043"), Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere", CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
Name = "Kohlfuchs, hell", Name = "Rotfuchsschimmel",
SortOrder = 42 SortOrder = 42
}, },
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 = "Goldfuchs, hell", Name = "Polarfuchs, hell",
SortOrder = 43 SortOrder = 43
}, },
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 = "Goldfuchsschimmel", Name = "Kohlfuchsschimmel, hell",
SortOrder = 44 SortOrder = 44
}, },
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 = "Gold-Hell", Name = "Rotfuchs, hell",
SortOrder = 45 SortOrder = 45
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000047"), Id = new Guid("00000000-0000-0000-0000-000000000047"),
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Siam (Marder-Hell) dd", Name = "Kohlfuchs-Hell",
SortOrder = 46 SortOrder = 46
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000048"), Id = new Guid("00000000-0000-0000-0000-000000000048"),
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Marder dd", Name = "Algierfuchs, hell",
SortOrder = 47 SortOrder = 47
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000049"), Id = new Guid("00000000-0000-0000-0000-000000000049"),
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Zobel-Hell", Name = "Topas dd",
SortOrder = 48 SortOrder = 48
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000050"), Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
Name = "Silberagouti dd CP", Name = "Blaufuchs dd",
SortOrder = 49 SortOrder = 49
}, },
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 = "Silberagouti-Hell dd CP", Name = "Marder",
SortOrder = 50 SortOrder = 50
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000052"), Id = new Guid("00000000-0000-0000-0000-000000000052"),
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
Name = "Agouti dd CP", Name = "Siam",
SortOrder = 51 SortOrder = 51
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000053"), Id = new Guid("00000000-0000-0000-0000-000000000053"),
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
Name = "Agouti-Hell dd CP", Name = "Zobel-Hell",
SortOrder = 52 SortOrder = 52
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000054"), Id = new Guid("00000000-0000-0000-0000-000000000054"),
CanonicalGenotype = "aa CC DD ee gg PP spsp rere", CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "Blaufuchs, hell", Name = "CP-Agouti",
SortOrder = 53 SortOrder = 53
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000055"), Id = new Guid("00000000-0000-0000-0000-000000000055"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere", CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
Name = "Rotfuchsschimmel", Name = "CP-Silberagouti",
SortOrder = 54 SortOrder = 54
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000056"), Id = new Guid("00000000-0000-0000-0000-000000000056"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere", CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "Polarfuchs, hell", Name = "CP-Algierfuchs",
SortOrder = 55 SortOrder = 55
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000057"), Id = new Guid("00000000-0000-0000-0000-000000000057"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere", CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "Kohlfuchsschimmel, hell", Name = "CP-Polarfuchs",
SortOrder = 56 SortOrder = 56
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000058"), Id = new Guid("00000000-0000-0000-0000-000000000058"),
CanonicalGenotype = "aa CC DD ee GG pp spsp rere", CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
Name = "Rotfuchs, hell", Name = "CP-Fuchs",
SortOrder = 57 SortOrder = 57
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000059"), Id = new Guid("00000000-0000-0000-0000-000000000059"),
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
Name = "Zobel dd", Name = "CP-Fuchs-Hell",
SortOrder = 58 SortOrder = 58
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000060"), Id = new Guid("00000000-0000-0000-0000-000000000060"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere", CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
Name = "Kohlfuchs-Hell", Name = "CP-Blaufuchs",
SortOrder = 59 SortOrder = 59
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000061"), Id = new Guid("00000000-0000-0000-0000-000000000061"),
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
Name = "Kohlfuchs CP",
SortOrder = 60
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "Algierfuchs CP",
SortOrder = 61
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
Name = "Silberagouti CP",
SortOrder = 62
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "Agouti CP",
SortOrder = 63
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000065"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "Algierfuchs-Hell CP",
SortOrder = 64
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
Name = "Kohlfuchs,hell CP",
SortOrder = 65
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000067"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "Polarfuchs CP",
SortOrder = 66
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000068"),
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Algierfuchs, hell",
SortOrder = 67
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000069"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Topas dd",
SortOrder = 68
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000070"),
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
Name = "Zobel-Hell dd",
SortOrder = 69
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000071"),
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel CP",
SortOrder = 70
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000072"),
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
Name = "Blaufuchs dd",
SortOrder = 71
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000073"),
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 = 72 SortOrder = 60
}); });
}); });
@@ -1073,6 +1021,20 @@ namespace GerbilManagerWebAPI.Migrations
Slug = "kontakt", Slug = "kontakt",
Status = "Published", Status = "Published",
Title = "Kontakt" Title = "Kontakt"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000007"),
Slug = "impressum",
Status = "Published",
Title = "Impressum"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000008"),
Slug = "datenschutz",
Status = "Published",
Title = "Datenschutz"
}); });
}); });

View File

@@ -51,7 +51,8 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
| ~~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 | | ~~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 |
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen | | C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen | | C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| C8 | **„Himalaya" vs. „Hermelin":** Du hast gesagt `c[h]c[h]` = **Hermelin**. In unserem Katalog gibt es aktuell ZWEI Farben mit `c[h]c[h]`: **Hermelin** (`aa c[h]c[h]`, nicht-agouti) und **Himalaya** (`A- c[h]c[h]`, agouti). Gibt es bei dir „Himalaya" überhaupt, oder ist **alles** mit `c[h]c[h]` einfach **Hermelin** (dann nehmen wir „Himalaya" raus, wie bei Schwarzschimmel)? | Farbschlag-Katalog (Himalaya behalten oder entfernen) | | ~~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) ### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
„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.)* „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.)*
@@ -60,6 +61,8 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien) ## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art. 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`. Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
@@ -84,20 +87,52 @@ Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt) ~~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? ### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
~~Firefly v.d. K.C. (*18.12.2019, DD↔D-)~~ ✅ **D- ist richtig** (DD war ein Tippfehler) — Julian 2026-06-06 · Zuleika v.d. K.C. (*24.10.2015) · ~~WildFire v.d. K.C. (*05.10.2017, PP↔P-)~~**PP ist richtig** (Julian 2026-06-06) · Milka of LennyLengo (*09.12.2018) · Silvain v.d. K.C. (*27.03.2022) · Daja of Little Rose (*16.05.2021, spsp) · Ichika v.d. K.C. (*19.04.2020) · Chelsea v.d. K.C. (*02.04.2021) 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? ### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
Vestra von den Schlossmäusen (*08.02.2019, WP) · Hedwig of BGB (*30.10.2019, DP + hörend) · Pitari gen. Piti v.d. K.C. (*16.05.2021, DP) · Little Hero of Black Forest (*22.02.2018, WFNZ ± spsp) · Victoria Welby gen. Welby v.d. K.C. (*16.01.2023, DP + Genotyp Ee[f]↔ee[f]) > ✅ **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 ### D5 · **Sterbedatum** widersprüchlich
| Tier | Problem | | Tier | Problem |
|---|---| |---|---|
| Flint v.d. Kleinen Chaoten (*23.12.2017) | Tod 10.05.**2021** vs. 10.05.**2022** (Jahr-Tippfehler?) | | ~~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 | | 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.2021 — VOR der Geburt** → klarer Datenfehler, bitte korrigieren | | ~~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.)* *(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) | P-Locus: **PP****Pp** |
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp****spsp** (Schecke ja/nein) |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016****2018** |
*(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) ## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
@@ -124,7 +159,7 @@ sagen, was ergänzt oder gestrichen werden soll:**
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) | | Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe | | E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** | | NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte/Sonder-Kürzel) | in Quarantäne | **C2C6** (C1 ✅ erledigt → D2-Gruppe + Uw/Marker-Tiere lädt Michael nach) | | Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) | | Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
--- ---

View File

@@ -1,18 +0,0 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
version: '3.4'
services:
gerbilmanagerwebapi:
image: gerbilmanagerwebapi
build:
context: .
dockerfile: GerbilManagerWebAPI/Dockerfile
args:
- configuration=Debug
ports:
- 80:80
environment:
- ASPNETCORE_ENVIRONMENT=Development
volumes:
- ~/.vsdbg:/remote_debugger:rw

View File

@@ -1,57 +0,0 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
version: '3.4'
services:
frontend:
image: gerbilmanagerweb
build:
context: .
dockerfile: gerbil-manager-web/Dockerfile
args:
# Der Browser erreicht die API über den am Host veröffentlichten Port
- VITE_API_BASE_URL=http://localhost:80
ports:
- 3000:3000
networks:
- net2
depends_on:
- backend
backend:
image: gerbilmanagerwebapi
build:
context: .
dockerfile: GerbilManagerWebAPI/Dockerfile
environment:
- ConnectionStrings:sqlConnection=server=database; database=GerbilManager; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True
ports:
- 80:80
networks:
- net1
- net2
depends_on:
- database
database:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=StrongerThenYYou1
- MSSQL_PID=Evaluation
ports:
- "1433:1433"
volumes:
- db-data:/var/opt/mssql
networks:
- net1
volumes:
db-data:
networks:
net1:
name: network1
net2:
name: network2

View File

@@ -0,0 +1,43 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Bestand ist sichtbar …
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// … aber die externe Ahne „Max“ (isResident=false) nicht.
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
})
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
await expect(maxRow).toBeVisible()
await expect(maxRow.getByText(t.externalBadge, { exact: true })).toBeVisible()
// Der Bestand bleibt weiterhin sichtbar.
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
})
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
skipUnlessMock()
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
await page.goto('/rennmaeuse/kruemel/bearbeiten')
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
await expect(check).toBeChecked()
await check.uncheck()
await page.getByRole('button', { name: t.form.save }).click()
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
})

View File

@@ -135,6 +135,29 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'PUT') return json(route, 204) if (method === 'PUT') return json(route, 204)
} }
// ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ──
if (path === '/render-site' && method === 'GET') {
const files = db.pages
.filter((p) => p.status === 'Published')
.map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 }))
return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files])
}
const pv = path.match(/^\/preview(?:\/(.*))?$/)
if (pv && method === 'GET') {
const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html'
if (key === 'assets/site.css') {
return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' })
}
const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '')
const p = db.pages.find((x) => x.slug === slug && x.status === 'Published')
if (!p) return json(route, 404, { title: 'Not Found' })
return route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
})
}
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ── // ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
if (path === '/requests/sync' && method === 'POST') { if (path === '/requests/sync' && method === 'POST') {
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' }) return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })

View File

@@ -88,6 +88,8 @@ function gerbil(
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''), nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
genotype, genotype,
notes: null, notes: null,
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
isResident: true,
} }
} }
@@ -110,8 +112,9 @@ export function seedDb(): MockDb {
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'), gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'), gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'), gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), // BESTAND-FILTER: externe Ahnen (Gründertiere fremder Zuchten, nur für den Stammbaum).
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), { ...gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), isResident: false, originBreeder: 'Zoohandlung Meier' },
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve // Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' }, { ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' }, { ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },

View File

@@ -0,0 +1,66 @@
/**
* WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die
* gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/
* Desktop-Umschalter, Einstiege von der Übersicht.
* Mock-gebunden (Seed-Seiten) → skipUnlessMock.
*/
import { de, expect, skipUnlessMock, test } from './fixtures'
const tw = de.pages.webseite
const tv = tw.vorschau
test.describe('Webseiten-Vorschau', () => {
test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite')
await page.getByRole('link', { name: tv.button }).click()
await expect(page.getByRole('heading', { name: tv.title })).toBeVisible()
await expect(page.getByText(tv.intro)).toBeVisible()
// Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible()
})
test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite/vorschau')
const select = page.getByLabel(tv.pageSelect)
// 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen
await expect(select.locator('option')).toHaveCount(2)
await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0)
await select.selectOption({ label: 'Abgabetiere' })
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
})
test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite/vorschau')
// Standard: Smartphone-Rahmen
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
await page.getByRole('button', { name: tv.viewDesktop }).click()
await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0)
await page.getByRole('button', { name: tv.viewPhone }).click()
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
})
test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite')
// Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht
const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' })
const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' })
await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible()
await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0)
await abgabeCard.getByRole('link', { name: tv.openPage }).click()
await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/)
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
})
})

View File

@@ -24,6 +24,7 @@ import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage' import EinstellungenPage from './pages/EinstellungenPage'
import WebseitePage from './pages/WebseitePage' import WebseitePage from './pages/WebseitePage'
import WebseiteEditorPage from './pages/WebseiteEditorPage' import WebseiteEditorPage from './pages/WebseiteEditorPage'
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage' import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage' import AnfrageDetailPage from './pages/AnfrageDetailPage'
@@ -72,6 +73,8 @@ export default function App() {
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */} {/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
<Route path="webseite"> <Route path="webseite">
<Route index element={<WebseitePage />} /> <Route index element={<WebseitePage />} />
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
<Route path="vorschau" element={<WebseiteVorschauPage />} />
<Route path=":slug" element={<WebseiteEditorPage />} /> <Route path=":slug" element={<WebseiteEditorPage />} />
</Route> </Route>
{/* INBOX-1: Anfragen-Posteingang */} {/* INBOX-1: Anfragen-Posteingang */}

View File

@@ -14,7 +14,15 @@
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein * ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten). * typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
*/ */
import { api } from './client' import { API_BASE_URL, api } from './client'
/**
* WEB-3-Fix: Die CMS-Endpunkte liegen unter der /api-Gruppe
* (CmsEndpoints: MapGroup("/api")) — die Aufrufe hier liefen vorher gegen
* /pages und wären gegen die ECHTE API 404 gelaufen (der e2e-Mock hat den
* Unterschied kaschiert, weil er das /api-Präfix normalisiert).
*/
const CMS = '/api'
export type PageStatus = 'Draft' | 'Published' export type PageStatus = 'Draft' | 'Published'
@@ -121,29 +129,39 @@ export function defaultBlockData(type: BlockType): BlockData {
// ── API-Aufrufe ────────────────────────────────────────────────────────────── // ── API-Aufrufe ──────────────────────────────────────────────────────────────
export function listPages(): Promise<PageSummary[]> { export function listPages(): Promise<PageSummary[]> {
return api.get<PageSummary[]>('/pages') return api.get<PageSummary[]>(`${CMS}/pages`)
} }
export function getPage(slug: string): Promise<Page> { export function getPage(slug: string): Promise<Page> {
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`) return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
} }
export function updatePage(id: string, input: PageInput): Promise<void> { export function updatePage(id: string, input: PageInput): Promise<void> {
return api.put<void>(`/pages/${id}`, input) return api.put<void>(`${CMS}/pages/${id}`, input)
} }
export function addBlock(pageId: string, input: BlockInput): Promise<Block> { export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
return api.post<Block>(`/pages/${pageId}/blocks`, input) return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
} }
export function updateBlock(id: string, input: BlockInput): Promise<void> { export function updateBlock(id: string, input: BlockInput): Promise<void> {
return api.put<void>(`/blocks/${id}`, input) return api.put<void>(`${CMS}/blocks/${id}`, input)
} }
export function deleteBlock(id: string): Promise<void> { export function deleteBlock(id: string): Promise<void> {
return api.delete(`/blocks/${id}`) return api.delete(`${CMS}/blocks/${id}`)
} }
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> { export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds }) return api.put<void>(`${CMS}/pages/${pageId}/blocks/order`, { blockIds })
}
/**
* WEB-3: Absolute URL der lokalen Vorschau (GET /api/preview/… rendert live).
* "start" liegt auf index.html, alle anderen Seiten auf {slug}/index.html —
* dieselbe Abbildung wie im SiteRenderer.
*/
export function previewUrl(slug?: string | null): string {
const path = !slug || slug === 'start' ? 'index.html' : `${encodeURIComponent(slug)}/index.html`
return `${API_BASE_URL}/api/preview/${path}`
} }

View File

@@ -47,6 +47,13 @@ export interface Gerbil {
*/ */
characterTraits?: string[] | null characterTraits?: string[] | null
characterNote?: string | null characterNote?: string | null
/**
* Residency (Bestand): true = part of the own clan (Kleine Chaoten), false =
* external/pedigree-only ancestor imported for the Stammbaum. Gridify-filterable
* (`isResident==true`); the Tiere list defaults to resident-only. Absent = treat
* as resident (backend default true).
*/
isResident?: boolean
} }
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */
@@ -68,6 +75,7 @@ export interface CreateGerbil {
notes?: string | null notes?: string | null
characterTraits?: string[] | null characterTraits?: string[] | null
characterNote?: string | null characterNote?: string | null
isResident?: boolean
} }
/** Payload for PUT /gerbils/{id} (all optional / partial update). */ /** Payload for PUT /gerbils/{id} (all optional / partial update). */

View File

@@ -330,6 +330,24 @@ textarea {
color: #3a6ea5; color: #3a6ea5;
} }
/* BESTAND-FILTER: subtle marker for external (non-Bestand) animals. Inline next
to the name, so it should not be pushed to the grid edge like the status badge. */
.badge--external {
justify-self: start;
margin-left: 0.4rem;
vertical-align: middle;
background: #f0f0f0;
color: #777;
border: 1px dashed #bbb;
}
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
.field--check {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.gerbil-card { .gerbil-card {
grid-template-columns: 2fr 1fr 1.5fr 1fr auto; grid-template-columns: 2fr 1fr 1.5fr 1fr auto;

View File

@@ -104,6 +104,11 @@ export default function GerbilDetailPage() {
<GerbilProfilePhoto gerbilId={g.id} /> <GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name}</h2> <h2>{g.name}</h2>
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span> <span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
{de.pages.gerbils.externalBadge}
</span>
)}
</div> </div>
<div className="head-actions"> <div className="head-actions">
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">

View File

@@ -24,6 +24,7 @@ interface FormState {
receiverContactId: string receiverContactId: string
genotype: string genotype: string
notes: string notes: string
isResident: boolean
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
receiverContactId: '', receiverContactId: '',
genotype: '', genotype: '',
notes: '', notes: '',
isResident: true,
} }
function formFromGerbil(g: { function formFromGerbil(g: {
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
receiverContactId: string | null receiverContactId: string | null
genotype: string | null genotype: string | null
notes: string | null notes: string | null
isResident?: boolean | null
}): FormState { }): FormState {
return { return {
name: g.name, name: g.name,
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
receiverContactId: g.receiverContactId ?? '', receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '', genotype: g.genotype ?? '',
notes: g.notes ?? '', notes: g.notes ?? '',
isResident: g.isResident ?? true,
} }
} }
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
receiverContactId: nn(form.receiverContactId), receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype), genotype: nn(form.genotype),
notes: nn(form.notes), notes: nn(form.notes),
isResident: form.isResident,
} }
const result = await mutation.run(body) const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`) if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} /> <textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label> </label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>
<input
type="checkbox"
checked={form.isResident}
onChange={(e) => set('isResident', e.target.checked)}
/>
</label>
{mutation.error && <div className="alert alert--error">{mutation.error}</div>} {mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions"> <div className="form-actions">

View File

@@ -32,6 +32,8 @@ export default function GerbilsPage() {
const [gender, setGender] = useState<Gender | ''>('') const [gender, setGender] = useState<Gender | ''>('')
const [colorVarietyId, setColorVarietyId] = useState('') const [colorVarietyId, setColorVarietyId] = useState('')
const [originBreeder, setOriginBreeder] = useState('') const [originBreeder, setOriginBreeder] = useState('')
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
const [showExternal, setShowExternal] = useState(false)
const [sort, setSort] = useState<SortKey>('nameAsc') const [sort, setSort] = useState<SortKey>('nameAsc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -50,6 +52,9 @@ export default function GerbilsPage() {
gender && condition({ field: 'gender', op: '==', value: gender }), gender && condition({ field: 'gender', op: '==', value: gender }),
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }), colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }), originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
// restrict to resident animals (server-side, so paging/counts stay correct).
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
) )
const orderBy = SORT_ORDER_BY[sort] const orderBy = SORT_ORDER_BY[sort]
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
@@ -62,6 +67,7 @@ export default function GerbilsPage() {
setGender('') setGender('')
setColorVarietyId('') setColorVarietyId('')
setOriginBreeder('') setOriginBreeder('')
setShowExternal(false)
setPage(1) setPage(1)
setSort('nameAsc') setSort('nameAsc')
} }
@@ -186,6 +192,14 @@ export default function GerbilsPage() {
<option value="birthAsc">{t.sort.birthAsc}</option> <option value="birthAsc">{t.sort.birthAsc}</option>
</select> </select>
</label> </label>
<label className="field field--check" title={t.filters.showExternalHint}>
<span>{t.filters.showExternal}</span>
<input
type="checkbox"
checked={showExternal}
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/>
</label>
<button type="button" className="btn" onClick={resetFilters}> <button type="button" className="btn" onClick={resetFilters}>
{t.filters.reset} {t.filters.reset}
</button> </button>
@@ -234,7 +248,14 @@ export default function GerbilsPage() {
aria-label={g.name} aria-label={g.name}
/> />
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card"> <Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name">{g.name}</span> <span className="gerbil-card__name">
{g.name}
{g.isResident === false && (
<span className="badge badge--external" title={t.externalTitle}>
{t.externalBadge}
</span>
)}
</span>
<span className={`badge badge--${g.status.toLowerCase()}`}> <span className={`badge badge--${g.status.toLowerCase()}`}>
{statusLabel(g.status)} {statusLabel(g.status)}
</span> </span>

View File

@@ -41,6 +41,12 @@ export default function WebseitePage() {
<h2>{t.title}</h2> <h2>{t.title}</h2>
<p className="muted">{t.intro}</p> <p className="muted">{t.intro}</p>
</div> </div>
{/* WEB-3: lokale Vorschau der gerenderten Webseite */}
<div className="head-actions">
<Link to="/webseite/vorschau" className="btn btn--primary">
{t.vorschau.button}
</Link>
</div>
</header> </header>
{pages.data.length === 0 ? ( {pages.data.length === 0 ? (
@@ -53,6 +59,12 @@ export default function WebseitePage() {
<span className="gerbil-card__meta">/{p.slug}</span> <span className="gerbil-card__meta">/{p.slug}</span>
<span className="webseite-card__actions"> <span className="webseite-card__actions">
<StatusBadge status={p.status} /> <StatusBadge status={p.status} />
{/* WEB-3: veröffentlichte Seiten direkt in der Vorschau öffnen */}
{p.status === 'Published' && (
<Link to={`/webseite/vorschau?seite=${encodeURIComponent(p.slug)}`} className="btn">
{t.vorschau.openPage}
</Link>
)}
<Link to={`/webseite/${p.slug}`} className="btn btn--primary"> <Link to={`/webseite/${p.slug}`} className="btn btn--primary">
{t.edit} {t.edit}
</Link> </Link>

View File

@@ -0,0 +1,121 @@
/**
* WEB-3: Lokale Vorschau der öffentlichen Webseite (/webseite/vorschau).
*
* Zeigt die vom Backend live gerenderte Seite (GET /api/preview/…) in einem
* iframe — exakt das HTML/CSS, das später veröffentlicht wird. Navigation
* INNERHALB der Vorschau funktioniert über die relativen Links der
* gerenderten Seite selbst; zusätzlich gibt es einen Seiten-Wechsler,
* einen Smartphone/Desktop-Umschalter und „Neu laden“.
* Nur veröffentlichte Seiten werden gerendert (SiteRenderer überspringt
* Entwürfe) — der Hinweis dazu steht über der Vorschau.
* Veröffentlichen selbst ist WEB-2 (gated) — hier gibt es bewusst keinen
* Publish-Knopf.
*/
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { listPages, previewUrl } from '../api/pages'
import { useApi } from '../hooks/useApi'
import './webseiteVorschau.css'
type Viewport = 'phone' | 'desktop'
export default function WebseiteVorschauPage() {
const t = de.pages.webseite.vorschau
const [params] = useSearchParams()
const pages = useApi(() => listPages(), [])
const published = useMemo(
() => (pages.data ?? []).filter((p) => p.status === 'Published'),
[pages.data],
)
const requested = params.get('seite')
const [selected, setSelected] = useState<string | null>(requested)
const slug =
(selected && published.some((p) => p.slug === selected) ? selected : null) ??
(published.some((p) => p.slug === 'start') ? 'start' : (published[0]?.slug ?? null))
const [viewport, setViewport] = useState<Viewport>('phone')
const [reloadKey, setReloadKey] = useState(0)
if (pages.loading) return <p className="muted">{de.common.loading}</p>
if (pages.error) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{pages.error}</span>
<button type="button" className="btn" onClick={pages.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
return (
<section className="page vorschau-page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{t.intro}</p>
</div>
<div className="head-actions">
<Link to="/webseite" className="btn">
{de.pages.webseite.back}
</Link>
</div>
</header>
{slug === null ? (
<p className="muted">{t.empty}</p>
) : (
<>
<div className="vorschau-toolbar">
<label className="field">
<span>{t.pageSelect}</span>
<select value={slug} onChange={(e) => setSelected(e.target.value)}>
{published.map((p) => (
<option key={p.id} value={p.slug}>
{p.title}
</option>
))}
</select>
</label>
<div className="vorschau-viewports" role="group" aria-label={`${t.viewPhone} / ${t.viewDesktop}`}>
<button
type="button"
className={viewport === 'phone' ? 'btn btn--primary' : 'btn'}
onClick={() => setViewport('phone')}
>
{t.viewPhone}
</button>
<button
type="button"
className={viewport === 'desktop' ? 'btn btn--primary' : 'btn'}
onClick={() => setViewport('desktop')}
>
{t.viewDesktop}
</button>
</div>
<button type="button" className="btn" onClick={() => setReloadKey((k) => k + 1)}>
{t.reload}
</button>
</div>
<div className={viewport === 'phone' ? 'vorschau-frame vorschau-frame--phone' : 'vorschau-frame'}>
<iframe
key={`${slug}:${reloadKey}`}
className="vorschau-iframe"
title={t.frameTitle}
src={previewUrl(slug)}
/>
</div>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,37 @@
/* WEB-3: Webseiten-Vorschau — seitenspezifische Stile
(Standing-Rule-Muster: eigene Datei statt index.css). */
.vorschau-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin: 0.75rem 0;
}
.vorschau-viewports {
display: flex;
gap: 0.35rem;
}
.vorschau-frame {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background: var(--color-surface);
overflow: hidden;
height: min(70dvh, 50rem);
}
/* Smartphone-Ansicht: schmaler Rahmen, mittig — wie ein Handy auf dem Tisch. */
.vorschau-frame--phone {
max-width: 400px;
margin-inline: auto;
}
.vorschau-iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}

View File

@@ -49,6 +49,9 @@ export const de = {
empty: 'Keine Rennmäuse gefunden.', empty: 'Keine Rennmäuse gefunden.',
countLabel: 'Tiere', countLabel: 'Tiere',
searchPlaceholder: 'Name suchen …', searchPlaceholder: 'Name suchen …',
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
externalBadge: 'Extern',
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
// Filter-Beschriftungen // Filter-Beschriftungen
filters: { filters: {
title: 'Filter', title: 'Filter',
@@ -58,6 +61,9 @@ export const de = {
all: 'Alle', all: 'Alle',
reset: 'Filter zurücksetzen', reset: 'Filter zurücksetzen',
sortBy: 'Sortieren nach', sortBy: 'Sortieren nach',
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
showExternal: 'Externe Ahnen einblenden',
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
}, },
// Sortier-Optionen // Sortier-Optionen
sort: { sort: {
@@ -110,6 +116,9 @@ export const de = {
none: '— keine Angabe —', none: '— keine Angabe —',
genotypeHint: genotypeHint:
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.', 'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',
@@ -646,6 +655,22 @@ export const de = {
statusPublished: 'Veröffentlicht', statusPublished: 'Veröffentlicht',
edit: 'Bearbeiten', edit: 'Bearbeiten',
back: 'Zurück zur Übersicht', back: 'Zurück zur Übersicht',
// ── WEB-3 (Kelly): lokale Vorschau ──
vorschau: {
button: 'Vorschau',
title: 'Vorschau der Webseite',
intro:
'So sieht deine Webseite nach dem Veröffentlichen aus. Entwürfe erscheinen hier noch nicht.',
pageSelect: 'Seite',
reload: 'Neu laden',
viewPhone: 'Smartphone',
viewDesktop: 'Desktop',
frameTitle: 'Vorschau der öffentlichen Webseite',
empty:
'Noch keine veröffentlichte Seite — stelle eine Seite auf „Veröffentlicht“, um die Vorschau zu sehen.',
draftHint: 'Entwurf — erscheint noch nicht in der Vorschau.',
openPage: 'Ansehen',
},
// Seiten-Editor (Kopf) // Seiten-Editor (Kopf)
editor: { editor: {
pageTitleLabel: 'Seitentitel', pageTitleLabel: 'Seitentitel',

View File

@@ -1,5 +1,5 @@
{ {
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts `genotype` (and/or `farbschlag`) as authoritative and skips the conflict. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.", "_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"resolutions": [ "resolutions": [
{ {
"name": "Firefly von den Kleinen Chaoten", "name": "Firefly von den Kleinen Chaoten",
@@ -14,6 +14,76 @@
"decision": "P-locus = PP (not P-)", "decision": "P-locus = PP (not P-)",
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp", "genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3" "source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Flint von den Kleinen Chaoten",
"dob": "23.12.2017",
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
"dateOfDeath": "10.05.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Molly of Black Forest",
"dob": "13.09.2021",
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
"dateOfDeath": "03.05.2022",
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
},
{
"name": "Daja of Little Rose",
"dob": "16.05.2021",
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Ichika von den Kleinen Chaoten",
"dob": "19.04.2020",
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
"genotype": "aa CC D- ee[f] Gg pp spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
},
{
"name": "Zuleika von den Kleinen Chaoten",
"dob": "24.10.2015",
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Milka of LennyLengo",
"dob": "09.12.2018",
"decision": "C-locus = Cc[h], E-locus = EE",
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Silvain von den Kleinen Chaoten",
"dob": "27.03.2022",
"decision": "E-locus = ee, P-locus = Pp",
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Chelsea von den Kleinen Chaoten",
"dob": "15.10.2021",
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
"correctDob": "02.04.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
},
{
"name": "Vestra von den Schlossmäusen",
"dob": "08.02.2019",
"decision": "D-locus = DD",
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
"dob": "16.01.2023",
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
} }
] ]
} }

View File

@@ -158,6 +158,10 @@ def parse_detail(text):
tail = text[dob.end():] tail = text[dob.end():]
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
tail = tail.lstrip(" ,").strip() tail = tail.lstrip(" ,").strip()
# FIX-4 (Skarlett): strip trailing "/ +YEAR" death-year artifacts leaked from compact
# chart cells (e.g. "… rere / +2018"). The DEATH regex still captures the year from
# the full cell text, so it appears as a death-date conflict — not a genotype conflict.
tail = re.sub(r"\s*/\s*\+\d{4}\s*$", "", tail).strip()
if gt.looks_like_genotype(tail): if gt.looks_like_genotype(tail):
geno = tail geno = tail
return (dob.group(1) if dob else "", return (dob.group(1) if dob else "",
@@ -211,6 +215,11 @@ def extract_stammbaum(path):
farbschlag = "" farbschlag = ""
geno = geno0 geno = geno0
breeder = "" breeder = ""
# BAND-AWARE (Julian-confirmed): early bands (gen 0-1, cols B/E/H) are 5-cell blocks
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
deep_band = gen_of(c) >= 2
for rr in range(r + 1, r + 4): for rr in range(r + 1, r + 4):
cell = cells.get((c, rr)) cell = cells.get((c, rr))
if not cell: if not cell:
@@ -221,7 +230,7 @@ def extract_stammbaum(path):
elif re.search(r"\b(Zucht|Privatzucht)\b", cell) or cell.startswith("("): elif re.search(r"\b(Zucht|Privatzucht)\b", cell) or cell.startswith("("):
breeder = cell breeder = cell
used.add((c, rr)) used.add((c, rr))
elif not farbschlag and not re.match(r"^\*?\s?\d", cell) \ elif not deep_band and not farbschlag and not re.match(r"^\*?\s?\d", cell) \
and not looks_like_animal_name(cell): and not looks_like_animal_name(cell):
farbschlag = cell farbschlag = cell
used.add((c, rr)) used.add((c, rr))
@@ -497,6 +506,53 @@ def _geno_key(genodict):
return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m)) return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m))
# --- "presence wins" merge rule (Julian) -------------------------------------
# When two source variants of the SAME animal differ ONLY by a token PRESENT in one and
# ABSENT in the other — a whole locus (e.g. spsp recorded in one chart, omitted in another)
# or a modifier on the same base allele (e^f vs e, i.e. the [f] marker) — keep the present
# token; that is NOT a conflict. A genuine VALUE contradiction (different filled alleles:
# E vs e, D vs d, c^h vs c^chm) OR unknown-vs-filled (D- vs DD, the '?' second allele) STILL
# quarantines for human decision. (Markers/flags WP/DP/WFNZ/hörend are already tags/flags,
# never part of the genotype, so they never reach here.)
def _split_allele(a):
return tuple(a.split("^", 1)) if "^" in a else (a, "")
def _alleles_compatible(a, b):
if a == b:
return True
if a == "?" or b == "?":
return True # specific-wins: unknown allele is compatible with any
# specified value (C- vs CC -> CC; G- vs Gg -> Gg)
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
if ba != bb:
return False # different base allele = real value diff (E vs e, D vs d)
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
def _pair_compatible(p, q):
if len(p) != 2 or len(q) != 2:
return p == q
return ((_alleles_compatible(p[0], q[0]) and _alleles_compatible(p[1], q[1])) or
(_alleles_compatible(p[0], q[1]) and _alleles_compatible(p[1], q[0])))
def _genotype_conflict(mapped_list):
"""True only if two variants GENUINELY contradict at a shared locus. A locus present in
one variant and absent in another is fine (presence wins); so is a modifier present-vs-
absent on the same base allele. Replaces the old `len(distinct geno keys) > 1` test."""
loci = set()
for m in mapped_list:
loci.update(m.keys())
for locus in loci:
pairs = [m[locus] for m in mapped_list if locus in m]
for i in range(len(pairs)):
for j in range(i + 1, len(pairs)):
if not _pair_compatible(pairs[i], pairs[j]):
return True
return False
def dedup(animals): def dedup(animals):
"""Merge by normalise(call-name)+DOB, with the canonical Zucht as """Merge by normalise(call-name)+DOB, with the canonical Zucht as
DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht = DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht =
@@ -547,6 +603,7 @@ def dedup(animals):
parent_refs = list(base["parentRefs"]) parent_refs = list(base["parentRefs"])
genos = set() genos = set()
geno_keys = set() # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text geno_keys = set() # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text
mapped_variants = [] # mapped8locus per variant — for the 'presence wins' conflict test
farb = set() farb = set()
deaths = set() deaths = set()
deaf_seen = set() deaf_seen = set()
@@ -562,6 +619,7 @@ def dedup(animals):
if a["genotype"]["mapped8locus"]: if a["genotype"]["mapped8locus"]:
genos.add(a["genotype"]["rawGenotype"]) genos.add(a["genotype"]["rawGenotype"])
geno_keys.add(_geno_key(a["genotype"])) geno_keys.add(_geno_key(a["genotype"]))
mapped_variants.append(a["genotype"]["mapped8locus"])
if a["farbschlag"]: if a["farbschlag"]:
farb.add(a["farbschlag"]) farb.add(a["farbschlag"])
if a["death"]: if a["death"]:
@@ -569,9 +627,12 @@ def dedup(animals):
if a.get("deaf") is not None: if a.get("deaf") is not None:
deaf_seen.add(a["deaf"]) deaf_seen.add(a["deaf"])
tags_set.update(a.get("tags", [])) tags_set.update(a.get("tags", []))
# pick the richest genotype (most mapped loci, then longest raw) # pick the richest genotype: most mapped loci, then fewest unknowns ('?' alleles = specific
# wins, FIX-2), then longest raw string as final tiebreaker.
def _specificity(gd):
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
best = max((a["genotype"] for a in grp), best = max((a["genotype"] for a in grp),
key=lambda gd: (len(gd["mapped8locus"]), len(gd["rawGenotype"]))) key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
out = { out = {
"id": slug(base["name"], base["dob"]), "id": slug(base["name"], base["dob"]),
"name": base["name"], "name": base["name"],
@@ -598,8 +659,9 @@ def dedup(animals):
"conflict": False, "conflict": False,
} }
merged.append(out) merged.append(out)
# conflict: same animal, disagreeing NORMALIZED genotype (Uw==G) or farbschlag or death # conflict: same animal, GENUINELY disagreeing genotype (presence-vs-absence is NOT a
if len(geno_keys) > 1 or len(farb) > 1 or len(deaths) > 1: # conflict — Julian's 'presence wins') or >1 distinct farbschlag or >1 distinct death.
if _genotype_conflict(mapped_variants) or len(farb) > 1 or len(deaths) > 1:
out["conflict"] = True out["conflict"] = True
conflicts.append({ conflicts.append({
"id": out["id"], "name": base["name"], "dob": out["dob"], "id": out["id"], "name": base["name"], "dob": out["dob"],
@@ -829,6 +891,90 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
# ------------------------------------------------------------------------ main # ------------------------------------------------------------------------ main
def apply_dob_remaps(raw_animals, path):
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
Tolerates a missing/garbled file. Returns the remap count.
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
remaps_full = {} # (nameCanon, zuchtCanon, dob) -> correctDob — decision carries Zucht
remaps_name = {} # (nameCanon, dob) -> correctDob — no Zucht in decision
try:
with open(path, encoding="utf-8") as fh:
for r in (json.load(fh).get("resolutions") or []):
if r.get("correctDob"):
nc, zc = canon_pair(r.get("name", ""))
dob = norm_dob(r.get("dob", ""))
if zc:
remaps_full[(nc, zc, dob)] = r["correctDob"]
else:
remaps_name[(nc, dob)] = r["correctDob"]
except (OSError, ValueError):
return 0
if not remaps_full and not remaps_name:
return 0
n = 0
for a in raw_animals:
nc, zc = canon_pair(a.get("name", ""))
dob = norm_dob(a.get("dob", ""))
new = remaps_full.get((nc, zc, dob)) or remaps_name.get((nc, dob))
if new and a.get("dob") != new:
a["dob"] = new
n += 1
return n
def apply_conflict_decisions(merged, conflicts, path):
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
farbschlag?, source}]}. Match = canon_pair(name)+(dob):
- When the decision name CARRIES a Zucht (zuchtCanon != ''), match on the FULL
(nameCanon, zuchtCanon, dob) triple — preserves the C3 rule that same name+DOB but
different Zucht = different animal.
- When the decision has NO Zucht, fall back to (nameCanon, dob) name-only match.
Both spellings v.d. / von den fold to the same canon. A matching animal: clear its conflict,
mark resolvedByDecision; an explicit `genotype` (breeder notation) is parsed and becomes
authoritative, `farbschlag` overrides too. Tolerates a missing/empty/garbled file.
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
try:
with open(path, encoding="utf-8") as fh:
for r in (json.load(fh).get("resolutions") or []):
nc, zc = canon_pair(r.get("name", ""))
dob = norm_dob(r.get("dob", ""))
if zc:
decisions_full[(nc, zc, dob)] = r
else:
decisions_name[(nc, dob)] = r
except (OSError, ValueError):
return 0
if not decisions_full and not decisions_name:
return 0
resolved = 0
for a in merged:
nc, zc = canon_pair(a["name"])
dob = norm_dob(a["dob"])
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
if not d:
continue
a["resolvedByDecision"] = True
if d.get("genotype"):
a["genotype"] = gt.parse(d["genotype"])
if d.get("farbschlag"):
a["farbschlag"] = d["farbschlag"]
a["farbschlagVariants"] = [d["farbschlag"]]
if d.get("dateOfDeath"): # D5 death-date resolutions
a["death"] = norm_dob(d["dateOfDeath"])
if a.get("conflict"):
a["conflict"] = False
conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]]
resolved += 1
return resolved
def main(): def main():
try: try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stdout.reconfigure(encoding="utf-8", errors="replace")
@@ -862,7 +1008,10 @@ def main():
litters = extract_wurfchronik(args.wurfchronik) litters = extract_wurfchronik(args.wurfchronik)
print(f"Wurfchronik: {len(litters)} Würfe") print(f"Wurfchronik: {len(litters)} Würfe")
decisions_path = os.path.join(HERE, "conflict-decisions.json")
dob_remaps = apply_dob_remaps(raw_animals, decisions_path) # before dedup (changes identity)
merged, conflicts, orphans, zucht_splits = dedup(raw_animals) merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path)
match_stats = match_litters(merged, litters) match_stats = match_litters(merged, litters)
photo_count = sum(len(a["photos"]) for a in merged) photo_count = sum(len(a["photos"]) for a in merged)
@@ -879,7 +1028,8 @@ def main():
zucht_splits, match_stats) zucht_splits, match_stats)
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} " print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
f"| Konflikte: {len(conflicts)} | Zucht-Splits: {len(zucht_splits)} " f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} "
f"| DOB-Remaps: {dob_remaps} | Zucht-Splits: {len(zucht_splits)} "
f"| Orphans: {len(orphans)} | Fotos: {photo_count}") f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), " print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig " f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "

View File

@@ -5,10 +5,10 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick ## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **950** - Rohe Tier-Einträge aus den Stammbäumen: **950**
- Nach Zusammenführung (eindeutige Tiere): **622** - Nach Zusammenführung (eindeutige Tiere): **621**
- davon mit Geburtsdatum: 327 - davon mit Geburtsdatum: 326
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
- Konflikte zur Klärung: **21** - Konflikte zur Klärung: **8**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
- Fotos zugeordnet: **137** - Fotos zugeordnet: **137**
- Würfe aus der Wurfchronik: **752** - Würfe aus der Wurfchronik: **752**
@@ -26,23 +26,10 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien | | Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids | | Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Firefly von den Kleinen Chaoten | 18.12.2019 | /+, Aa c[chm]c[chm] D- Ee Gg PP Spsp // Aa c[chm]c[chm] DD Ee Gg PP Spsp | — | 2024 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
| WildFire von den Kleinen Chaoten | 05.10.2017 | aa c[chm]c[chm] D- Ee gg P- spsp // aa c[chm]c[chm] D- Ee gg PP spsp | — | — | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance | | Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
| Milka of LennyLengo | 09.12.2018 | aa C- dd E- Gg P- Spsp // aa Cc[h] dd EE Gg P- Spsp | — | 22.12.2021 | Stammbaum von Alberto Kids, Stammbaum von Stella Kids |
| Silvain von den Kleinen Chaoten | 27.03.2022 | aa c[chm]c[chm] Dd Ee[-] Gg P- Spsp // aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp | — | 31.12.2024 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids | | Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
| Little Hero of Black Forest | 22.02.2018 | AA CC DD EE GG PP [WFNZ] // AA CC DD EE GG PP spsp [WFNZ] | — | 18.06.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids, Stammbaum von Valentino Firehearts Kids |
| Molly of Black Forest | 13.09.2021 | /+, Aa Cc[chm] D- Ee gg P- spsp // Aa Cc[chm] Dd Ee gg Pp spsp | — | 03.05.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids | | Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
| Daja of Little Rose | 16.05.2021 | aa chmchm D- EE Gg P- // aa chmchm D- EE Gg P- spsp | — | — | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids | | Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
| Ichika von den Kleinen Chaoten | 19.04.2020 | aa CC D- ee Gg pp spsp // aa CC D- ee[f] Gg pp spsp | — | 27.11.2023 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Watarus Kids |
| Victoria Welby gen. Welby v.d. Kleinen Chaoten | 16.01.2023 | Aa CC D- Ee[f] Gg pp Spsp [DP] // Aa CC D- ee[f] Gg pp Spsp [DP] | Goldfuchsschimmel Punktschecke DP | 17.02.2026 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
| Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids | | Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids | | Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance | | Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
@@ -103,7 +90,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019) - „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016) - „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016)
- „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016) - „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016)
- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021); Chelsea von den Kleinen Chaoten (*15.10.2021) - „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016) - „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016) - „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018) - „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018)
@@ -142,15 +129,15 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| Token | Vorkommen | Bedeutung (Vermutung) | | Token | Vorkommen | Bedeutung (Vermutung) |
|---|---|---| |---|---|---|
| `/+` | 8 | ? | | `/+` | 7 | ? |
| `-g` | 2 | ? | | `-g` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) | | `C(C)` | 2 | Schreibweise (C trägt c) |
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
| `Cc[]` | 1 | ? | | `Cc[]` | 1 | ? |
| `-psp` | 1 | ? | | `-psp` | 1 | ? |
| `G(G)` | 1 | ? | | `G(G)` | 1 | ? |
| `/` | 1 | ? | | `/` | 1 | ? |
| `+2018` | 1 | ? | | `+2018` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? | | `c[chm]chm]` | 1 | ? |
| `Dea/dea]` | 1 | ? | | `Dea/dea]` | 1 | ? |
| `DD-Tumor` | 1 | ? | | `DD-Tumor` | 1 | ? |

View File

@@ -0,0 +1,286 @@
"""Zero-dep tests for extract.py band-aware Farbschlag + name-bleed guard.
Run: python test_extract.py (exit 0 = all pass)
Covers (PEDIGREE-LINK / Julian-confirmed): deep pedigree bands (gen >= 2, cols K/N/Q...)
are Name/DOB/Genotype ONLY — no Farbschlag cell — so a stray health note or the next
block's name must NOT be captured as Farbschlag; early bands (gen 0-1) keep their real
Farbschlag. Plus the looks_like_animal_name guard (a parent name must not be a Farbschlag).
"""
import os
import sys
import zipfile
import tempfile
import extract as e
failed = 0
def check(name, cond):
global failed
print(("ok: " if cond else "FAIL: ") + name)
if not cond:
failed += 1
def _cell(ref, text):
return f'<c r="{ref}" t="inlineStr"><is><t>{text}</t></is></c>'
def _make_xlsx(path, cells):
"""cells: {(colLetter+row): text}. Build a minimal single-sheet xlsx (no styles)."""
rows = {}
for ref, text in cells.items():
r = int("".join(ch for ch in ref if ch.isdigit()))
rows.setdefault(r, []).append(_cell(ref, text))
body = "".join(f'<row r="{r}">{"".join(cs)}</row>' for r, cs in sorted(rows.items()))
sheet = ('<?xml version="1.0"?><worksheet xmlns="http://x"><sheetData>'
+ body + "</sheetData></worksheet>")
with zipfile.ZipFile(path, "w") as z:
z.writestr("xl/worksheets/sheet1.xml", sheet)
# --- band-aware Farbschlag ---
# col E = gen 0 (early, HAS Farbschlag); col K = col 11 = gen 2 (deep, NO Farbschlag).
tmp = os.path.join(tempfile.gettempdir(), "bandtest.xlsx")
_make_xlsx(tmp, {
# early band (E): Name / *DOB / Farbschlag / Genotype
"E10": "Chesnut",
"E11": "*13.11.2019",
"E12": "Kohlfuchsschimmel",
"E13": "aa CC DD ee GG PP spsp rere",
# deep band (K): Name / *DOB / Genotype / stray NOTE (must NOT become Farbschlag)
"K10": "DeepAnimal",
"K11": "*01.01.2020",
"K12": "aa CC DD EE GG PP spsp rere",
"K13": "DD-Tumor",
})
try:
animals = e.extract_stammbaum(tmp)
by_name = {a["name"]: a for a in animals}
check("early band keeps real Farbschlag",
by_name.get("Chesnut", {}).get("farbschlag") == "Kohlfuchsschimmel")
check("deep band has NO Farbschlag (note not grabbed)",
by_name.get("DeepAnimal", {}).get("farbschlag") == "")
check("deep-band animal still parsed (Name/DOB/Genotype)",
"DeepAnimal" in by_name and by_name["DeepAnimal"]["dob"].startswith("01.01"))
finally:
try: os.remove(tmp)
except OSError: pass
gen = e.gen_of
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2)
# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")
import json as _json
_json.dump({"resolutions": [
{"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
"decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
"source": "test"},
{"name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
"decision": "Todesdatum 10.05.2021 (2022 war Tippfehler)", "dateOfDeath": "10.05.2021",
"source": "test"},
]}, open(dec_path, "w", encoding="utf-8"))
merged = [
{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
"conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}},
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
"conflict": True, "farbschlag": "", "death": "10.05.2022",
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"},
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017"}]
n = e.apply_conflict_decisions(merged, conflicts, dec_path)
check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False)
check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True)
check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"])
check("decision dateOfDeath is authoritative (D5)", merged[1]["death"] == "10.05.2021")
check("decision removes both entries from conflicts list", conflicts == [])
check("apply_conflict_decisions returns resolved count", n == 2)
check("missing decisions file tolerated (returns 0)",
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
_json.dump({"resolutions": [
{"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den'
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
]}, open(dec_vd, "w", encoding="utf-8"))
merged_vd = [
{"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.'
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts_vd = [{"id": "vw"}]
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
_json.dump({"resolutions": [
{"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
]}, open(dec_vd, "w", encoding="utf-8"))
merged_vd2 = [
{"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den'
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts_vd2 = [{"id": "vw2"}]
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
try: os.remove(dec_vd)
except OSError: pass
# FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation)
dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json")
_json.dump({"resolutions": [
# Decision only for Luna from ZdkC, NOT Luna from Black Forest
{"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
"decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"},
]}, open(dec_c3, "w", encoding="utf-8"))
merged_c3 = [
{"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
"conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
{"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020",
"conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
]
conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}]
n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3)
check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1)
check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False)
check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True)
check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf")
try: os.remove(dec_c3)
except OSError: pass
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
_json.dump({"resolutions": [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021",
"decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"},
]}, open(dec2, "w", encoding="utf-8"))
raw = [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"}, # the wrong-dob duplicate
{"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"}, # canonical
{"name": "Other Animal", "dob": "01.01.2020"},
]
rn = e.apply_dob_remaps(raw, dec2)
check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021")
check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021")
check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020")
check("apply_dob_remaps returns remap count", rn == 1)
check("after remap both Chelsea share one dedup identity (name+dob)",
e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"]))
check("missing decisions file tolerated for dob remaps (returns 0)",
e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0)
try: os.remove(dec2)
except OSError: pass
try: os.remove(dec_path)
except OSError: pass
# --- "presence wins" + "specific wins" conflict rules (Julian) ---
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are.
# FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict —
# the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP).
check("spsp present vs locus absent -> no conflict",
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
# FIX-2: '?' vs specified = specific wins (was: contradiction)
check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict",
not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict",
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict",
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}]))
check("FIX-2: G- vs Gg (specific wins) -> NOT conflict",
not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}]))
check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict",
not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}]))
# Genuine value contradictions (both alleles specified but different) still quarantine
check("Ee vs ee (different base allele) -> conflict",
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
check("DD vs Dd (both specified, D vs d) -> conflict",
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
check("PP vs Pp (both specified) -> conflict",
e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}]))
check("c[h] vs c[chm] (different modifiers, both specified) -> conflict",
not e._alleles_compatible("c^h", "c^chm"))
check("identical genotypes -> no conflict",
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
# FIX-2 MERGE: specific allele must survive the merge regardless of which variant comes first.
# dedup() picks the most specific genotype (fewest '?' alleles); C- vs CC -> CC must win.
def _minimal_animal(name, dob, mapped):
"""Build a minimal raw animal dict suitable for dedup()."""
from genotype import parse as gparse
raw = " ".join(f"{l}{''.join(a)}" for l, pa in mapped.items() for a in [pa])
return {
"name": name, "dob": dob, "death": "", "gender": None,
"farbschlag": "", "breeder": "", "zucht": "", "parentRefs": [],
"photos": [], "sourceFiles": ["test.xlsx"], "tags": [],
"deaf": None, "conflict": False,
"genotype": {"mapped8locus": mapped, "rawGenotype": raw, "unmappedTokens": []},
"_gen": 0, "_col": 5, "_row": 10, "_file": "test.xlsx",
"_zucht": "",
}
# Order A: C- first, CC second
animals_merge_a = [
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "?"]}), # C-
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "C"]}), # CC
]
merged_ma, _, _, _ = e.dedup(animals_merge_a)
check("FIX-2 merge A (C- first): result has CC not C-",
merged_ma[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
# Order B: CC first, C- second (must give same result)
animals_merge_b = [
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "C"]}), # CC
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "?"]}), # C-
]
merged_mb, _, _, _ = e.dedup(animals_merge_b)
check("FIX-2 merge B (CC first): result has CC not C-",
merged_mb[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
# G- vs Gg: Gg must win
animals_merge_g = [
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "?"]}), # G-
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "g"]}), # Gg
]
merged_mg, _, _, _ = e.dedup(animals_merge_g)
check("FIX-2 merge G (G- vs Gg): Gg wins",
merged_mg[0]["genotype"]["mapped8locus"].get("G") == ["G", "g"])
# --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured ---
dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018")
check("FIX-4: '/ +YEAR' artifact stripped from geno tail",
geno4 == "aa C- DD ee Gg PP spsp rere")
check("FIX-4: death year still captured from full cell text",
death4 == "2018")
check("FIX-4: DOB still correct",
dob4 == "17.04.2016")
# Without artifact — must be unchanged
dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere")
check("FIX-4: no artifact -> geno unchanged",
geno5 == "aa C- DD ee Gg PP spsp rere")
check("FIX-4: no artifact -> no spurious death",
death5 == "")
# --- name-bleed guard (a parent name is not a Farbschlag) ---
check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten"))
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)
print("\nALL PASS")