Compare commits
18 Commits
feature/ge
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
| 54ffb809c4 | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 92dd614269 | |||
| 097a04cbd8 |
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
public static class ImportDocxEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/import/docx").WithTags("Import");
|
||||||
|
|
||||||
|
// POST /import/docx/dry-run — analyse without writing
|
||||||
|
group.MapPost("/dry-run",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
|
||||||
|
|
||||||
|
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
|
||||||
|
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
|
||||||
|
group.MapPost("/execute",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
259
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
||||||
|
/// docx_animals.json (produced by extract_docx.py) and enriches the database:
|
||||||
|
///
|
||||||
|
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||||
|
/// - Litter link: match docx WS-code to Litters.PairingCode → set Gerbil.LitterId
|
||||||
|
/// for animals matched by normalize(name) + litter birth date.
|
||||||
|
/// - ReceiverContact: lookup-or-create Contact by owner name → set ReceiverContactId.
|
||||||
|
/// - GoHomeDate, DateOfDeath, CauseOfDeath: fill if currently null (fill-NULL-only).
|
||||||
|
/// - NEVER overwrites a manually-set non-null value.
|
||||||
|
///
|
||||||
|
/// Idempotent: running multiple times is safe. Each run resolves whatever is still null.
|
||||||
|
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImportDocxService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
private readonly ApplicationContext _db;
|
||||||
|
private readonly string _sourceDir;
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||||
|
: this(db,
|
||||||
|
config["Import:SourcePath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, string sourceDir)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_sourceDir = sourceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImportDocxReport> RunAsync(bool execute)
|
||||||
|
{
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||||
|
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||||
|
|
||||||
|
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||||
|
{
|
||||||
|
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
||||||
|
"extract_docx.py zuerst ausführen.");
|
||||||
|
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup: PairingCode → Litter.Id (WS-code normalised: spaces removed)
|
||||||
|
var littersInDb = await _db.Litters
|
||||||
|
.Where(l => l.PairingCode != null)
|
||||||
|
.Select(l => new { l.Id, l.Date, l.PairingCode })
|
||||||
|
.ToListAsync();
|
||||||
|
var litterByWs = littersInDb
|
||||||
|
.GroupBy(l => l.PairingCode!.Replace(" ", ""))
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Build animal lookup: normalize(name) + litter_dob → Gerbil (for litter-link)
|
||||||
|
var gerbilsInDb = await _db.Gerbils
|
||||||
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
||||||
|
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath })
|
||||||
|
.ToListAsync();
|
||||||
|
var gerbilByKey = gerbilsInDb
|
||||||
|
.Where(g => g.DateOfBirth is not null)
|
||||||
|
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Contact lookup: normalized name → existing Contact
|
||||||
|
var contactsInDb = await _db.Contacts
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
var contactByNorm = contactsInDb
|
||||||
|
.GroupBy(c => NormalizeName(c.Name))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
int litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
||||||
|
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||||
|
|
||||||
|
foreach (var da in docxAnimals)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Resolve the litter by WS-code + approximate birth date
|
||||||
|
Guid? litterId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
|
||||||
|
{
|
||||||
|
var litterDob = ParseDate(da.LitterDob);
|
||||||
|
if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands))
|
||||||
|
{
|
||||||
|
// Pick the litter whose date matches (within ±5 days for rounding)
|
||||||
|
var match = cands.FirstOrDefault(l =>
|
||||||
|
Math.Abs((l.Date.DayNumber - litterDob.Value.DayNumber)) <= 5);
|
||||||
|
litterId = match?.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the gerbil by name + litter birth date
|
||||||
|
var animalDob = litterId is not null
|
||||||
|
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
||||||
|
: ParseDate(da.LitterDob);
|
||||||
|
|
||||||
|
if (animalDob is null) { skipped++; continue; }
|
||||||
|
|
||||||
|
var key = NameDobKey(da.Name, animalDob.Value);
|
||||||
|
if (!gerbilByKey.TryGetValue(key, out var gerbilCands)) { skipped++; continue; }
|
||||||
|
|
||||||
|
// If multiple gerbils match (same name+dob), take the one without a litter link first
|
||||||
|
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
||||||
|
?? gerbilCands.First();
|
||||||
|
|
||||||
|
// Resolve receiver contact (lookup-or-create)
|
||||||
|
Guid? receiverId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.Owner))
|
||||||
|
{
|
||||||
|
var normOwner = NormalizeName(da.Owner);
|
||||||
|
if (contactByNorm.TryGetValue(normOwner, out var existingId))
|
||||||
|
{
|
||||||
|
receiverId = existingId;
|
||||||
|
ownerLinked++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ownerCreated++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||||
|
_db.Contacts.Add(newContact);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
receiverId = newContact.Id;
|
||||||
|
contactByNorm[normOwner] = receiverId.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var goHomeDate = ParseDate(da.AbgabeDate);
|
||||||
|
var deathDate = ParseDate(da.DeathDate);
|
||||||
|
|
||||||
|
// Count what will change
|
||||||
|
bool willLinkLitter = litterId is not null && gerbilSnap.LitterId is null;
|
||||||
|
bool willFillGoHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
||||||
|
bool willFillDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
||||||
|
|
||||||
|
if (willLinkLitter) litterLinked++;
|
||||||
|
if (willFillGoHome) goHomeFilled++;
|
||||||
|
if (willFillDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
|
||||||
|
if (willLinkLitter) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null)
|
||||||
|
row.ReceiverContactId = receiverId;
|
||||||
|
if (willFillGoHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willFillDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execute && (litterLinked + goHomeFilled + deathFilled + ownerLinked + ownerCreated) > 0)
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||||
|
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (WS-Code → PairingCode).");
|
||||||
|
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||||
|
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||||
|
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||||
|
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein DB-Match).");
|
||||||
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
|
||||||
|
|
||||||
|
return new ImportDocxReport(execute, litterLinked, ownerLinked + ownerCreated,
|
||||||
|
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Load<T>(string file)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_sourceDir, file);
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDate(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(s,
|
||||||
|
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||||
|
if (!m.Success) return null;
|
||||||
|
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||||
|
int y = int.Parse(m.Groups[3].Value);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
try { return new DateOnly(y, mo, d); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameDobKey(string name, DateOnly dob)
|
||||||
|
{
|
||||||
|
var n = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
|
return $"{n}|{dob:yyyy-MM-dd}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeName(string name)
|
||||||
|
{
|
||||||
|
var n = (name ?? "").ToLowerInvariant();
|
||||||
|
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Source shapes (from extract_docx.py output) ----
|
||||||
|
|
||||||
|
public sealed class DocxLitter
|
||||||
|
{
|
||||||
|
public string LitterId { get; set; } = "";
|
||||||
|
public string Dob { get; set; } = "";
|
||||||
|
public string MotherName { get; set; } = "";
|
||||||
|
public string FatherName { get; set; } = "";
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DocxAnimal
|
||||||
|
{
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string LitterDob { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Farbschlag { get; set; } = "";
|
||||||
|
public string Gender { get; set; } = "";
|
||||||
|
public string Owner { get; set; } = "";
|
||||||
|
public string AbgabeDate { get; set; } = "";
|
||||||
|
public string AbgabeWeight { get; set; } = "";
|
||||||
|
public string DeathDate { get; set; } = "";
|
||||||
|
public string DeathCause { get; set; } = "";
|
||||||
|
public string PartnerName { get; set; } = "";
|
||||||
|
public string PartnerDob { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report ----
|
||||||
|
|
||||||
|
public sealed record ImportDocxReport(
|
||||||
|
bool Executed,
|
||||||
|
int LitterLinked,
|
||||||
|
int OwnerLinked,
|
||||||
|
int GoHomeFilled,
|
||||||
|
int DeathFilled,
|
||||||
|
int ContactsCreated,
|
||||||
|
int Skipped,
|
||||||
|
IReadOnlyList<string> Notes);
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ app.MapInbreedingEndpoints();
|
|||||||
app.MapPhotoEndpoints();
|
app.MapPhotoEndpoints();
|
||||||
app.MapSaleAdEndpoints();
|
app.MapSaleAdEndpoints();
|
||||||
app.MapImportEndpoints();
|
app.MapImportEndpoints();
|
||||||
|
app.MapImportDocxEndpoints();
|
||||||
app.MapContractEndpoints();
|
app.MapContractEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
|
|||||||
207
HUMANQUESTION.md
207
HUMANQUESTION.md
@@ -2,150 +2,80 @@
|
|||||||
|
|
||||||
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
||||||
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
||||||
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
|
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
|
||||||
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
|
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
|
||||||
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
|
> das, was **noch offen** ist. Nichts geht verloren.
|
||||||
> alles wartet geduldig, bis ihr antwortet.
|
|
||||||
|
|
||||||
_Stand: 2026-06-06._
|
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
|
||||||
|
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
|
||||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
|
||||||
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
|
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
|
||||||
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
|
Feinschliff** — siehe unten._
|
||||||
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Für Julian — Konten, Schlüssel, Technik
|
## A. Zugänge & Technik (für Julian)
|
||||||
|
|
||||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
| # | Was gebraucht wird | Schaltet frei |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| ~~A1~~ | ✅ **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
||||||
| ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
|
| **A4** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
|
||||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
|
||||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo → Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) |
|
||||||
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
|
||||||
|
|
||||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
## B. Kleine Aktion (jederzeit)
|
||||||
|
|
||||||
| # | Aktion | Warum |
|
| # | Aktion | Warum |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
|
| **B2** | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt (http://192.168.2.124:5173). |
|
||||||
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
|
|
||||||
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
|
|
||||||
|
|
||||||
---
|
## C. Genetik-Feinschliff (optional, blockiert nichts)
|
||||||
|
|
||||||
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
|
| # | Frage | Betrifft |
|
||||||
|
|
||||||
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
|
|
||||||
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|
||||||
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
|
|
||||||
|
|
||||||
| # | Frage | Blockiert |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
|
| **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. |
|
||||||
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
|
| **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). |
|
||||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
|
||||||
| ~~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 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
|
||||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
|
||||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
|
||||||
|
|
||||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
## E. Charakterbogen-Eigenschaften (optional)
|
||||||
„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.)*
|
|
||||||
|
|
||||||
---
|
Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?**
|
||||||
|
|
||||||
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
|
|
||||||
|
|
||||||
> ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
|
|
||||||
|
|
||||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
|
||||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
|
||||||
|
|
||||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
|
||||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
|
||||||
|
|
||||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
|
||||||
|---|---|---|
|
|
||||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
|
||||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
|
||||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
|
||||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
|
||||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
|
||||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
|
||||||
|
|
||||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
|
||||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
|
||||||
|
|
||||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
|
||||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
|
||||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
|
||||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
|
||||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
|
||||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
|
||||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
|
||||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
|
||||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
|
||||||
|
|
||||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
|
||||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
|
||||||
>
|
|
||||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
|
||||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
|
|
||||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
|
||||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
|
||||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
|
||||||
|
|
||||||
### D5 · **Sterbedatum** widersprüchlich
|
|
||||||
| Tier | Problem |
|
|
||||||
|---|---|
|
|
||||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
|
||||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
|
||||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
|
||||||
|
|
||||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
|
||||||
|
|
||||||
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? |
|
|
||||||
|---|---|
|
|
||||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
|
||||||
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
|
|
||||||
|
|
||||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
|
|
||||||
|
|
||||||
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
|
|
||||||
sagen, was ergänzt oder gestrichen werden soll:**
|
|
||||||
|
|
||||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
||||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
||||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
||||||
|
|
||||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
|
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?)
|
||||||
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
|
- **E2** — Auch ehrliche **negative** Eigenschaften aufnehmen (z. B. *beißt manchmal*) für ehrliche Inserate?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
||||||
|
|
||||||
|
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||||
|
|
||||||
|
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||||
|
| Tier | Sterbedatum — welches? |
|
||||||
|
|---|---|
|
||||||
|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
|
||||||
|
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
|
||||||
|
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
|
||||||
|
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 |
|
||||||
|
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
|
||||||
|
|
||||||
|
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||||
|
| Tier | Konflikt — was stimmt? |
|
||||||
|
|---|---|
|
||||||
|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** |
|
||||||
|
| Percy of little runners (*16.12.2017) | P-Locus: **PP** ↔ **Pp** |
|
||||||
|
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** ↔ **Pp** |
|
||||||
|
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-** ↔ **Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** |
|
||||||
|
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
|
||||||
|
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
|
||||||
|
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** |
|
||||||
|
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
|
||||||
|
|
||||||
|
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,16 +83,25 @@ sagen, was ergänzt oder gestrichen werden soll:**
|
|||||||
|
|
||||||
| Funktion | Status | Wartet auf |
|
| Funktion | Status | Wartet auf |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
|
||||||
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
|
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
|
||||||
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
|
| Öffentliche Webseite + KI-Chat-Editor | gebaut | **A4** (Domain + Cloudflare) |
|
||||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
|
||||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
|
||||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
|
||||||
| Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
|
|
||||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||||
|
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
|
## ✅ Erledigt (Archiv — nichts geht verloren)
|
||||||
sie hier und meldet, was dadurch live geht.*
|
|
||||||
|
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
|
||||||
|
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
|
||||||
|
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
|
||||||
|
- **C1–C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
|
||||||
|
- **D1–D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
|
||||||
|
|
||||||
|
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ npm run build
|
|||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Farbschlag-Katalog (AR-5)
|
||||||
|
|
||||||
|
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
|
||||||
|
Nach jeder Änderung dort den Generator laufen lassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run gen:catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugt zwei Artefakte und committet beide:
|
||||||
|
|
||||||
|
| Datei | Notation | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
|
||||||
|
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
|
||||||
|
|
||||||
|
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
|
||||||
|
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
|
||||||
|
|
||||||
## E2E-Tests (QA-1, Playwright)
|
## E2E-Tests (QA-1, Playwright)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 201, contract)
|
return json(route, 201, contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FEAT-NAMEGEN: /names/suggest
|
||||||
|
if (path === '/names/suggest' && method === 'GET') {
|
||||||
|
if (!db.namesConfigured) {
|
||||||
|
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
|
||||||
|
}
|
||||||
|
const letter = url.searchParams.get('letter')?.toUpperCase()
|
||||||
|
const allSuggestions = [
|
||||||
|
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
|
||||||
|
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
|
||||||
|
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
|
||||||
|
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
|
||||||
|
]
|
||||||
|
const result = letter
|
||||||
|
? allSuggestions.filter((s) => s.name.startsWith(letter))
|
||||||
|
: allSuggestions
|
||||||
|
return json(route, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface MockDb {
|
|||||||
// ABGABE: Verträge + KI-Inserat-Flag
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
contracts: MockContract[]
|
contracts: MockContract[]
|
||||||
saleAdConfigured: boolean
|
saleAdConfigured: boolean
|
||||||
|
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||||
|
namesConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -292,5 +294,6 @@ export function seedDb(): MockDb {
|
|||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
contracts: [],
|
contracts: [],
|
||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
|
namesConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.namegen
|
||||||
|
const tf = de.pages.gerbils.form
|
||||||
|
|
||||||
|
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Panel öffnen
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// Vorschläge laden
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf Vorschlag 'Fenrir'
|
||||||
|
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||||
|
|
||||||
|
// Panel geschlossen, Name-Feld befüllt
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||||
|
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByLabel(t.letterLabel).fill('F')
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
|
||||||
|
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||||
|
// Artemis (A) nicht sichtbar
|
||||||
|
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (mockDb) mockDb.namesConfigured = false
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -54,6 +54,23 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
|||||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
|
||||||
|
const nameLink = page.getByRole('link', { name: 'Fridolin' })
|
||||||
|
await expect(nameLink).toBeVisible()
|
||||||
|
// href korrekt gesetzt
|
||||||
|
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
|
||||||
|
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
|
||||||
|
await nameLink.focus()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
|
||||||
|
})
|
||||||
|
|
||||||
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||||
|
|||||||
@@ -172,3 +172,27 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
|
|||||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||||
|
await page.goto('/rennmaeuse/fridolin')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||||
|
// Wurf K als Link sichtbar
|
||||||
|
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||||
|
await expect(litterLink).toBeVisible()
|
||||||
|
// Vater-Badge sichtbar
|
||||||
|
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||||
|
// Navigation zum Wurf funktioniert
|
||||||
|
await litterLink.first().click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
49
gerbil-manager-web/gen-seed.mts
Normal file
49
gerbil-manager-web/gen-seed.mts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Katalog-Generator — AR-5
|
||||||
|
*
|
||||||
|
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
|
||||||
|
*
|
||||||
|
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
|
||||||
|
* → Quelle für UI-Dropdowns, Frontend-Suche.
|
||||||
|
*
|
||||||
|
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
|
||||||
|
* → Quelle für künftige EF-Seed-Migrationen (Pam).
|
||||||
|
* NICHT in Bracket-Notation ändern — Backend-Parser
|
||||||
|
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
|
||||||
|
*
|
||||||
|
* Ausführen nach jeder Änderung an catalog.ts:
|
||||||
|
* npm run gen:catalog
|
||||||
|
*
|
||||||
|
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
|
||||||
|
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
|
||||||
|
*/
|
||||||
|
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
|
||||||
|
import { LOCUS_ORDER } from './src/genetics/loci.ts'
|
||||||
|
import type { Genotype } from './src/genetics/genotype.ts'
|
||||||
|
import { writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
|
||||||
|
function toInternalString(g: Genotype): string {
|
||||||
|
return LOCUS_ORDER.filter(
|
||||||
|
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
|
)
|
||||||
|
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display artefact (bracket notation) ─────────────────────────────────────
|
||||||
|
const displayPath = './src/genetics/colorVarietySeed.generated.json'
|
||||||
|
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
|
||||||
|
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
|
||||||
|
|
||||||
|
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
|
||||||
|
const backendSeed = BASE_COLORS.map((entry, i) => ({
|
||||||
|
name: entry.name,
|
||||||
|
...(entry.english !== undefined ? { english: entry.english } : {}),
|
||||||
|
canonicalGenotype: toInternalString(representativeGenotype(entry)),
|
||||||
|
sortOrder: i,
|
||||||
|
...(entry.image !== undefined ? { image: entry.image } : {}),
|
||||||
|
}))
|
||||||
|
const backendPath = './src/genetics/colorVarietySeed.backend.json'
|
||||||
|
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
|
||||||
|
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test",
|
||||||
|
"gen:catalog": "npx tsx gen-seed.mts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
|
||||||
|
|
||||||
|
describe('buildSuggestPath', () => {
|
||||||
|
it('includes uppercased letter', () => {
|
||||||
|
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
|
||||||
|
expect(path).toContain('letter=F')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits letter when blank', () => {
|
||||||
|
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('letter=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits letter when only whitespace', () => {
|
||||||
|
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('letter=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes gender', () => {
|
||||||
|
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
|
||||||
|
expect(path).toContain('gender=male')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits gender for empty string', () => {
|
||||||
|
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('gender=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins multiple usages without encoding commas', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
|
||||||
|
expect(path).toContain('usages=norn,mythg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits usages when array is empty', () => {
|
||||||
|
const path = buildSuggestPath({ usages: [] })
|
||||||
|
expect(path).not.toContain('usages=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults count to 6', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'] })
|
||||||
|
expect(path).toContain('count=6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses provided count', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
|
||||||
|
expect(path).toContain('count=8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with /names/suggest', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'] })
|
||||||
|
expect(path).toMatch(/^\/names\/suggest\?/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NAMEGEN_USAGES', () => {
|
||||||
|
it('contains all 5 expected culture codes', () => {
|
||||||
|
const codes = NAMEGEN_USAGES.map((u) => u.code)
|
||||||
|
expect(codes).toContain('norn')
|
||||||
|
expect(codes).toContain('japa')
|
||||||
|
expect(codes).toContain('mythg')
|
||||||
|
expect(codes).toContain('ger')
|
||||||
|
expect(codes).toContain('arb')
|
||||||
|
expect(codes).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every usage has a non-empty label', () => {
|
||||||
|
for (const u of NAMEGEN_USAGES) {
|
||||||
|
expect(u.label.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
40
gerbil-manager-web/src/api/names.ts
Normal file
40
gerbil-manager-web/src/api/names.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface NameSuggestion {
|
||||||
|
name: string
|
||||||
|
meaning: string
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAMEGEN_USAGES = [
|
||||||
|
{ code: 'norn', label: 'Nordisch' },
|
||||||
|
{ code: 'japa', label: 'Japanisch' },
|
||||||
|
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||||
|
{ code: 'ger', label: 'Deutsch' },
|
||||||
|
{ code: 'arb', label: 'Arabisch' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||||
|
|
||||||
|
export interface SuggestNamesParams {
|
||||||
|
letter?: string
|
||||||
|
gender?: string
|
||||||
|
usages: NamegenUsageCode[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for unit tests — builds the query path without a network call. */
|
||||||
|
export function buildSuggestPath(params: SuggestNamesParams): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const letter = params.letter?.trim().toUpperCase()
|
||||||
|
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
|
||||||
|
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
|
||||||
|
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
|
||||||
|
parts.push(`count=${params.count ?? 6}`)
|
||||||
|
return `/names/suggest?${parts.join('&')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
|
||||||
|
return api.get<NameSuggestion[]>(buildSuggestPath(params))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ALL_TRAITS } from '../format/traits'
|
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||||
import './charakterbogen.css'
|
import './charakterbogen.css'
|
||||||
|
|
||||||
export interface CharakterbogenProps {
|
export interface CharakterbogenProps {
|
||||||
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
|
|||||||
onNoteChange: (note: string) => void
|
onNoteChange: (note: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
|
||||||
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
|
||||||
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
|
||||||
* de.character.traits; the stored value is the trait KEY.
|
|
||||||
*/
|
|
||||||
export default function Charakterbogen({
|
export default function Charakterbogen({
|
||||||
traits,
|
traits,
|
||||||
note,
|
note,
|
||||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
|||||||
const next = new Set(selected)
|
const next = new Set(selected)
|
||||||
if (next.has(key)) next.delete(key)
|
if (next.has(key)) next.delete(key)
|
||||||
else next.add(key)
|
else next.add(key)
|
||||||
// Preserve the vocabulary order for stable output.
|
// Preserve vocabulary (category) order for stable output.
|
||||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="charakterbogen">
|
<div className="charakterbogen">
|
||||||
|
{TRAIT_CATEGORIES.map((cat) => (
|
||||||
|
<section key={cat.category} className="trait-category">
|
||||||
|
<h4 className="trait-category__heading">{cat.category}</h4>
|
||||||
<ul className="trait-grid">
|
<ul className="trait-grid">
|
||||||
{ALL_TRAITS.map((tr) => (
|
{cat.traits.map((tr) => (
|
||||||
<li key={tr.key}>
|
<li key={tr.key}>
|
||||||
<label className="trait-chip">
|
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(tr.key)}
|
checked={selected.has(tr.key)}
|
||||||
onChange={() => toggle(tr.key)}
|
onChange={() => toggle(tr.key)}
|
||||||
/>
|
/>
|
||||||
<span>{tr.label}</span>
|
<span>{tr.label}</span>
|
||||||
|
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.noteLabel}</span>
|
<span>{t.noteLabel}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
.namegen-name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-name-row .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter-input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__usages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent, #2563eb);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__meaning {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__origin {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { ApiError, errorCode } from '../api/client'
|
||||||
|
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
|
||||||
|
import './NameSuggestPanel.css'
|
||||||
|
|
||||||
|
interface NameSuggestPanelProps {
|
||||||
|
gender: string
|
||||||
|
onPick: (name: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||||
|
|
||||||
|
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||||
|
const t = de.namegen
|
||||||
|
const [letter, setLetter] = useState('')
|
||||||
|
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [keyMissing, setKeyMissing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
|
||||||
|
const [fetched, setFetched] = useState(false)
|
||||||
|
|
||||||
|
function toggleUsage(code: NamegenUsageCode) {
|
||||||
|
setUsages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(code)) next.delete(code)
|
||||||
|
else next.add(code)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setKeyMissing(false)
|
||||||
|
setSuggestions([])
|
||||||
|
try {
|
||||||
|
const result = await suggestNames({
|
||||||
|
letter: letter.trim() || undefined,
|
||||||
|
gender: gender || undefined,
|
||||||
|
usages: [...usages] as NamegenUsageCode[],
|
||||||
|
count: 6,
|
||||||
|
})
|
||||||
|
setSuggestions(result)
|
||||||
|
setFetched(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (errorCode(err) === 'NamesKeyMissing') {
|
||||||
|
setKeyMissing(true)
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
|
||||||
|
<div className="namegen-panel__header">
|
||||||
|
<span>{t.panelTitle}</span>
|
||||||
|
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__filters">
|
||||||
|
<div className="namegen-panel__letter">
|
||||||
|
<label htmlFor="namegen-letter">{t.letterLabel}</label>
|
||||||
|
<input
|
||||||
|
id="namegen-letter"
|
||||||
|
className="input namegen-panel__letter-input"
|
||||||
|
value={letter}
|
||||||
|
onChange={(e) => setLetter(e.target.value)}
|
||||||
|
placeholder={t.letterPlaceholder}
|
||||||
|
maxLength={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__usages">
|
||||||
|
<span>{t.usagesLabel}</span>
|
||||||
|
<div className="namegen-usages-grid">
|
||||||
|
{NAMEGEN_USAGES.map(({ code, label }) => (
|
||||||
|
<label key={code}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={usages.has(code)}
|
||||||
|
onChange={() => toggleUsage(code)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading || usages.size === 0}
|
||||||
|
>
|
||||||
|
{loading ? t.loading : t.loadButton}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{keyMissing && <p className="muted">{t.keyMissing}</p>}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li key={`${s.name}-${i}`} className="namegen-suggestion">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="namegen-suggestion__pick"
|
||||||
|
onClick={() => onPick(s.name)}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
<span className="namegen-suggestion__meaning">{s.meaning}</span>
|
||||||
|
<span className="namegen-suggestion__origin">{s.origin}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
|
||||||
|
|
||||||
|
.trait-category {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-category__heading {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.charakterbogen .trait-grid {
|
.charakterbogen .trait-grid {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -39,3 +52,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trait-chip--warn {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-warn-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d97706;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
|
||||||
|
|
||||||
|
describe('trait catalog', () => {
|
||||||
|
it('has 4 categories', () => {
|
||||||
|
expect(TRAIT_CATEGORIES).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('category names match spec', () => {
|
||||||
|
const names = TRAIT_CATEGORIES.map((c) => c.category)
|
||||||
|
expect(names).toEqual([
|
||||||
|
'Sozialverhalten',
|
||||||
|
'Eignung & Umgang',
|
||||||
|
'Hobbys & Eigenarten',
|
||||||
|
'Wesen & Temperament',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ALL_TRAITS flattens all categories', () => {
|
||||||
|
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
|
||||||
|
expect(ALL_TRAITS).toHaveLength(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all keys are unique', () => {
|
||||||
|
const keys = ALL_TRAITS.map((t) => t.key)
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('existing 15 keys are still present (stored on live animals)', () => {
|
||||||
|
const legacy = [
|
||||||
|
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
|
||||||
|
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
|
||||||
|
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of legacy) {
|
||||||
|
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('new keys are present', () => {
|
||||||
|
const newKeys = [
|
||||||
|
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
|
||||||
|
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
|
||||||
|
'schredder', 'nestbauer', 'territorial',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of newKeys) {
|
||||||
|
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
|
||||||
|
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
|
||||||
|
expect(isWarnTrait('territorial')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-warn traits return false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('zutraulich')).toBe(false)
|
||||||
|
expect(isWarnTrait('dominant')).toBe(false)
|
||||||
|
expect(isWarnTrait('beobachtungstier')).toBe(false)
|
||||||
|
expect(isWarnTrait('schreckhaft')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown key returns false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('not-a-real-key')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns German label for known key', () => {
|
||||||
|
expect(traitLabel('zutraulich')).toBe('zutraulich')
|
||||||
|
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
|
||||||
|
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
|
||||||
|
expect(traitLabel('territorial')).toBe('territorial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns the key itself for unknown key', () => {
|
||||||
|
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels maps a list of keys to labels', () => {
|
||||||
|
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
|
||||||
|
'zutraulich',
|
||||||
|
'dominant (Leittier)',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels handles null and undefined gracefully', () => {
|
||||||
|
expect(traitLabels(null)).toEqual([])
|
||||||
|
expect(traitLabels(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
export const ALL_TRAITS = de.character.traits
|
export interface TraitEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
warn?: true
|
||||||
|
}
|
||||||
|
|
||||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
export interface TraitCategory {
|
||||||
|
category: string
|
||||||
|
traits: readonly TraitEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
|
||||||
|
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
|
||||||
|
|
||||||
|
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
|
||||||
|
|
||||||
|
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
|
||||||
|
|
||||||
export function traitLabel(key: string): string {
|
export function traitLabel(key: string): string {
|
||||||
return LABEL_BY_KEY.get(key) ?? key
|
return LABEL_BY_KEY.get(key) ?? key
|
||||||
@@ -13,3 +27,7 @@ export function traitLabel(key: string): string {
|
|||||||
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||||
return (keys ?? []).map(traitLabel)
|
return (keys ?? []).map(traitLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWarnTrait(key: string): boolean {
|
||||||
|
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
|
||||||
|
* aus catalog.ts übereinstimmen.
|
||||||
|
*
|
||||||
|
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
|
||||||
|
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
|
||||||
|
* und die geänderten JSON-Dateien committen.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { CATALOG } from '../catalog'
|
||||||
|
|
||||||
|
const __dir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
describe('AR-5 Catalog drift-guard', () => {
|
||||||
|
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
|
||||||
|
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
|
||||||
|
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
|
||||||
|
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
|
||||||
|
expect(committed).toEqual(Array.from(CATALOG))
|
||||||
|
})
|
||||||
|
})
|
||||||
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Pink Eyed White (PEW)",
|
||||||
|
"english": "Pink Eyed White",
|
||||||
|
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hermelin",
|
||||||
|
"english": "Dark Tailed White",
|
||||||
|
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"image": "hermelin.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Himalaya",
|
||||||
|
"english": "Himalayan",
|
||||||
|
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 2,
|
||||||
|
"image": "himalaya.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zobel",
|
||||||
|
"english": "Sable",
|
||||||
|
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 3,
|
||||||
|
"image": "zobel.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotaugenschimmel",
|
||||||
|
"english": "Red-Eyed Roan",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 4,
|
||||||
|
"image": "rotaugen-schimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Agouti",
|
||||||
|
"english": "Golden Agouti",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 5,
|
||||||
|
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schwarz",
|
||||||
|
"english": "Black",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 6,
|
||||||
|
"image": "schwarz.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silberagouti",
|
||||||
|
"english": "Grey Agouti",
|
||||||
|
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 7,
|
||||||
|
"image": "silberagouti.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anthrazit",
|
||||||
|
"english": "Slate",
|
||||||
|
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 8,
|
||||||
|
"image": "anthrazit.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchs",
|
||||||
|
"english": "Dark-Eyed Honey",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 9,
|
||||||
|
"image": "algierfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blau",
|
||||||
|
"english": "Blue",
|
||||||
|
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||||
|
"sortOrder": 10,
|
||||||
|
"image": "blau-schwarz-dd.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold",
|
||||||
|
"english": "Argente Golden",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 11,
|
||||||
|
"image": "gold.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Platin",
|
||||||
|
"english": "Lilac",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 12,
|
||||||
|
"image": "platin.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchs",
|
||||||
|
"english": "Yellow Fox",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 13,
|
||||||
|
"image": "goldfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchs",
|
||||||
|
"english": "Argente Nutmeg",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 14,
|
||||||
|
"image": "rotfuchs.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Gold",
|
||||||
|
"english": "dd Argente Golden",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 15,
|
||||||
|
"image": "gold-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Platin",
|
||||||
|
"english": "dd Lilac",
|
||||||
|
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 16,
|
||||||
|
"image": "platin-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Altweiss (REW)",
|
||||||
|
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||||
|
"sortOrder": 17,
|
||||||
|
"image": "altweiss-rew.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Apricot (Blassfuchs)",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||||
|
"sortOrder": 18,
|
||||||
|
"image": "apricot-blassfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchs",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 19,
|
||||||
|
"image": "blaufuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "C-Separator",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||||
|
"sortOrder": 20,
|
||||||
|
"image": "c-separator.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Elfenbein",
|
||||||
|
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||||
|
"sortOrder": 21,
|
||||||
|
"image": "elfenbein.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 22,
|
||||||
|
"image": "kohlfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 23,
|
||||||
|
"image": "polarfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Saphir",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 24,
|
||||||
|
"image": "saphir.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 25,
|
||||||
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Topas",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 26,
|
||||||
|
"image": "topas.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Platin-Hell",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 27,
|
||||||
|
"image": "platin-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Agouti",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
|
"sortOrder": 28,
|
||||||
|
"image": "agouti-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Silberagouti",
|
||||||
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
|
"sortOrder": 29,
|
||||||
|
"image": "silberagouti-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Kohlfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 30,
|
||||||
|
"image": "kohlfuchs-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Anthrazit",
|
||||||
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
|
"sortOrder": 31,
|
||||||
|
"image": "anthrazit-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Goldfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Rotfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silberschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 36,
|
||||||
|
"image": "silberschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 37,
|
||||||
|
"image": "polarfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 38,
|
||||||
|
"image": "algierfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 39,
|
||||||
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 40,
|
||||||
|
"image": "blaufuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 41,
|
||||||
|
"image": "kohlfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 42,
|
||||||
|
"image": "goldfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 43,
|
||||||
|
"image": "goldfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold-Hell",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 44,
|
||||||
|
"image": "gold-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 45,
|
||||||
|
"image": "blaufuchs-hell.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 46,
|
||||||
|
"image": "rotfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 47,
|
||||||
|
"image": "polarfuchs-hell.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 48,
|
||||||
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 49,
|
||||||
|
"image": "rotfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs-Hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 50,
|
||||||
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 51,
|
||||||
|
"image": "algierfuchs-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Topas",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 52,
|
||||||
|
"image": "topas-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Blaufuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
|
"sortOrder": 53,
|
||||||
|
"image": "blaufuchs-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marder",
|
||||||
|
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 54,
|
||||||
|
"image": "marder.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Siam",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 55,
|
||||||
|
"image": "siam-marder-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zobel-Hell",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 56,
|
||||||
|
"image": "zobel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 57,
|
||||||
|
"image": "agouti-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 59,
|
||||||
|
"image": "silberagouti-cp.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 61,
|
||||||
|
"image": "algierfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 63,
|
||||||
|
"image": "polarfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Blaufuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 69
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -105,14 +105,14 @@
|
|||||||
"image": "rotfuchs.JPG"
|
"image": "rotfuchs.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dd Gold",
|
"name": "Dilute Gold",
|
||||||
"english": "dd Argente Golden",
|
"english": "dd Argente Golden",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 15,
|
"sortOrder": 15,
|
||||||
"image": "gold-dd.jpg"
|
"image": "gold-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dd Platin",
|
"name": "Dilute Platin",
|
||||||
"english": "dd Lilac",
|
"english": "dd Lilac",
|
||||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 16,
|
"sortOrder": 16,
|
||||||
@@ -185,222 +185,242 @@
|
|||||||
"image": "platin-hell.jpg"
|
"image": "platin-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agouti dd",
|
"name": "Dilute Agouti",
|
||||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
"sortOrder": 28,
|
"sortOrder": 28,
|
||||||
"image": "agouti-dd.jpg"
|
"image": "agouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberagouti dd",
|
"name": "Dilute Silberagouti",
|
||||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 29,
|
"sortOrder": 29,
|
||||||
"image": "silberagouti-dd.jpg"
|
"image": "silberagouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs dd",
|
"name": "Dilute Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
"sortOrder": 30,
|
"sortOrder": 30,
|
||||||
"image": "kohlfuchs-dd.jpg"
|
"image": "kohlfuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Anthrazit dd",
|
"name": "Dilute Anthrazit",
|
||||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 31,
|
"sortOrder": 31,
|
||||||
"image": "anthrazit-dd.jpg"
|
"image": "anthrazit-dd.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Goldfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Rotfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 35
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"name": "Silberschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 32,
|
"sortOrder": 36,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 33,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 34,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 35,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 36,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs, hell",
|
"name": "Kohlfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 37,
|
"sortOrder": 41,
|
||||||
"image": "kohlfuchs-hell.jpg"
|
"image": "kohlfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs, hell",
|
"name": "Goldfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 38,
|
"sortOrder": 42,
|
||||||
"image": "goldfuchs-hell.jpg"
|
"image": "goldfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 39,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold-Hell",
|
"name": "Gold-Hell",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 40,
|
"sortOrder": 44,
|
||||||
"image": "gold-hell.jpg"
|
"image": "gold-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs, hell",
|
"name": "Blaufuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 41,
|
"sortOrder": 45,
|
||||||
"image": "blaufuchs-hell.jpeg"
|
"image": "blaufuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 42,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs, hell",
|
"name": "Polarfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 43,
|
"sortOrder": 47,
|
||||||
"image": "polarfuchs-hell.jpeg"
|
"image": "polarfuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 44,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs, hell",
|
"name": "Rotfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 45,
|
"sortOrder": 49,
|
||||||
"image": "rotfuchs-hell.jpg"
|
"image": "rotfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs-Hell",
|
"name": "Kohlfuchs-Hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 46,
|
"sortOrder": 50,
|
||||||
"image": "kohlfuchs-hell-2.jpg"
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs, hell",
|
"name": "Algierfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 47,
|
"sortOrder": 51,
|
||||||
"image": "algierfuchs-hell.JPG"
|
"image": "algierfuchs-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas dd",
|
"name": "Dilute Topas",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 48,
|
"sortOrder": 52,
|
||||||
"image": "topas-dd.jpg"
|
"image": "topas-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs dd",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
"sortOrder": 49,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marder",
|
"name": "Marder",
|
||||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 50,
|
"sortOrder": 54,
|
||||||
"image": "marder.JPG"
|
"image": "marder.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Siam",
|
"name": "Siam",
|
||||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 51,
|
"sortOrder": 55,
|
||||||
"image": "siam-marder-hell.JPG"
|
"image": "siam-marder-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel-Hell",
|
"name": "Zobel-Hell",
|
||||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 52,
|
"sortOrder": 56,
|
||||||
"image": "zobel-hell.jpg"
|
"image": "zobel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti",
|
"name": "CP-Agouti",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 53,
|
"sortOrder": 57,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti-Hell",
|
"name": "CP-Agouti-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 54
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 55,
|
"sortOrder": 59,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti-Hell",
|
"name": "CP-Silberagouti-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 56
|
"sortOrder": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||||
"sortOrder": 57,
|
"sortOrder": 61,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs-Hell",
|
"name": "CP-Algierfuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||||
"sortOrder": 58
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||||
"sortOrder": 59,
|
"sortOrder": 63,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs-Hell",
|
"name": "CP-Polarfuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||||
"sortOrder": 60
|
"sortOrder": 64
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||||
"sortOrder": 61
|
"sortOrder": 65
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"name": "CP-Fuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||||
"sortOrder": 62
|
"sortOrder": 66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"name": "CP-Blaufuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
||||||
"sortOrder": 63
|
"sortOrder": 67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"name": "CP-Orangeschimmel",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 64
|
"sortOrder": 68
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel-Hell",
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 65
|
"sortOrder": 69
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { getGerbil, updateGerbil } from '../api/gerbils'
|
import { getGerbil, updateGerbil } from '../api/gerbils'
|
||||||
|
import { listLitters as listLittersPaged } from '../api/litters'
|
||||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
|
|||||||
const enclosures = useApi(() => listEnclosures(), [])
|
const enclosures = useApi(() => listEnclosures(), [])
|
||||||
const contacts = useApi(() => listContacts(), [])
|
const contacts = useApi(() => listContacts(), [])
|
||||||
const litters = useApi(() => listLitters(), [])
|
const litters = useApi(() => listLitters(), [])
|
||||||
|
const parentLitters = useApi(
|
||||||
|
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
const colorName = useMemo(
|
const colorName = useMemo(
|
||||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||||
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
|
|||||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3>{t.detail.parentLittersTitle}</h3>
|
||||||
|
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||||
|
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||||
|
)}
|
||||||
|
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||||
|
<li key={litter.id}>
|
||||||
|
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||||
|
<span className="gerbil-card__name">{litter.name}</span>
|
||||||
|
<span className="badge badge--active">
|
||||||
|
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||||
|
</span>
|
||||||
|
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||||
|
{litter.totalBorn !== null && (
|
||||||
|
<span className="gerbil-card__meta">
|
||||||
|
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
|
|||||||
import { genderLabel, statusLabel } from '../format/labels'
|
import { genderLabel, statusLabel } from '../format/labels'
|
||||||
import { fromDisplayString } from '../genetics'
|
import { fromDisplayString } from '../genetics'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
|
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||||
|
import '../components/NameSuggestPanel.css'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
|
|||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [form, setForm] = useState<FormState>(EMPTY)
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||||
|
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||||
|
|
||||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||||
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
|
|||||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||||
|
|
||||||
<form className="form" onSubmit={onSubmit} noValidate>
|
<form className="form" onSubmit={onSubmit} noValidate>
|
||||||
<label className="field">
|
<div className="field">
|
||||||
<span>{t.fields.name} *</span>
|
<label htmlFor="gerbil-name">{t.fields.name} *</label>
|
||||||
|
<div className="namegen-name-row">
|
||||||
<input
|
<input
|
||||||
|
id="gerbil-name"
|
||||||
className="input"
|
className="input"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => set('name', e.target.value)}
|
onChange={(e) => set('name', e.target.value)}
|
||||||
aria-invalid={Boolean(errors.name)}
|
aria-invalid={Boolean(errors.name)}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => setShowNameSuggest((v) => !v)}
|
||||||
|
>
|
||||||
|
{de.namegen.button}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
{errors.name && <small className="error-text">{errors.name}</small>}
|
||||||
</label>
|
</div>
|
||||||
|
{showNameSuggest && (
|
||||||
|
<NameSuggestPanel
|
||||||
|
gender={form.gender}
|
||||||
|
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
|
||||||
|
onClose={() => setShowNameSuggest(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.gender} *</span>
|
<span>{t.fields.gender} *</span>
|
||||||
|
|||||||
@@ -377,7 +377,13 @@ function PedigreeCard({
|
|||||||
<div className="pedigree-card__body">
|
<div className="pedigree-card__body">
|
||||||
<div className="pedigree-card__name">
|
<div className="pedigree-card__name">
|
||||||
<SexIcon gender={g.gender} />
|
<SexIcon gender={g.gender} />
|
||||||
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
<Link
|
||||||
|
to={`/rennmaeuse/${g.id}`}
|
||||||
|
className="pedigree-card__nametext"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{g.name || de.pages.gerbils.nameless}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -134,6 +134,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__nametext:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pedigree-card__sex--male {
|
.pedigree-card__sex--male {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ export const de = {
|
|||||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||||
moreData: 'Weitere Daten',
|
moreData: 'Weitere Daten',
|
||||||
|
// ANIMAL-LITTERS: Würfe als Elternteil
|
||||||
|
parentLittersTitle: 'Würfe als Elternteil',
|
||||||
|
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
||||||
|
parentLittersRoleVater: 'Vater',
|
||||||
|
parentLittersRoleMutter: 'Mutter',
|
||||||
},
|
},
|
||||||
// Formular (anlegen/bearbeiten)
|
// Formular (anlegen/bearbeiten)
|
||||||
form: {
|
form: {
|
||||||
@@ -779,9 +784,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||||
},
|
},
|
||||||
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
|
||||||
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
|
||||||
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
|
||||||
character: {
|
character: {
|
||||||
sectionTitle: 'Charakter & Eigenschaften',
|
sectionTitle: 'Charakter & Eigenschaften',
|
||||||
noteLabel: 'Notizen zum Charakter',
|
noteLabel: 'Notizen zum Charakter',
|
||||||
@@ -789,6 +794,40 @@ export const de = {
|
|||||||
save: 'Charakter speichern',
|
save: 'Charakter speichern',
|
||||||
saved: 'Charakter gespeichert.',
|
saved: 'Charakter gespeichert.',
|
||||||
none: 'Noch keine Eigenschaften ausgewählt.',
|
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||||
|
warnLabel: '⚠ Warnsignal',
|
||||||
|
traitCategories: [
|
||||||
|
{
|
||||||
|
category: 'Sozialverhalten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'dominant', label: 'dominant (Leittier)' },
|
||||||
|
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
|
||||||
|
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
|
||||||
|
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Eignung & Umgang',
|
||||||
|
traits: [
|
||||||
|
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||||
|
{ key: 'erfahrene-halter', label: 'für erfahrene Halter' },
|
||||||
|
{ key: 'beobachtungstier', label: 'reines Beobachtungstier' },
|
||||||
|
{ key: 'familiengeeignet', label: 'familiengeeignet / stressresistent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Hobbys & Eigenarten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||||
|
{ key: 'buddelt', label: 'buddelt gern' },
|
||||||
|
{ key: 'klettert', label: 'klettert gern' },
|
||||||
|
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||||
|
{ key: 'schredder', label: 'Schredder-Meister' },
|
||||||
|
{ key: 'nestbauer', label: 'Nestbauer / Architekt' },
|
||||||
|
{ key: 'territorial', label: 'territorial', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Wesen & Temperament',
|
||||||
traits: [
|
traits: [
|
||||||
{ key: 'zutraulich', label: 'zutraulich' },
|
{ key: 'zutraulich', label: 'zutraulich' },
|
||||||
{ key: 'handzahm', label: 'handzahm' },
|
{ key: 'handzahm', label: 'handzahm' },
|
||||||
@@ -798,21 +837,31 @@ export const de = {
|
|||||||
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
||||||
{ key: 'verschmust', label: 'verschmust' },
|
{ key: 'verschmust', label: 'verschmust' },
|
||||||
{ key: 'eigenstaendig', label: 'eigenständig' },
|
{ key: 'eigenstaendig', label: 'eigenständig' },
|
||||||
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
|
||||||
{ key: 'futterfreudig', label: 'futterfreudig' },
|
|
||||||
{ key: 'buddelt', label: 'buddelt gern' },
|
|
||||||
{ key: 'klettert', label: 'klettert gern' },
|
|
||||||
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
|
||||||
{ key: 'vertraeglich', label: 'gut verträglich' },
|
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
filterPanel: {
|
filterPanel: {
|
||||||
toggleButton: 'Filter',
|
toggleButton: 'Filter',
|
||||||
resetButton: 'Filter zurücksetzen',
|
resetButton: 'Filter zurücksetzen',
|
||||||
closeButton: 'Schließen',
|
closeButton: 'Schließen',
|
||||||
},
|
},
|
||||||
|
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
|
||||||
|
namegen: {
|
||||||
|
button: 'Name vorschlagen',
|
||||||
|
panelTitle: 'Namensvorschläge',
|
||||||
|
letterLabel: 'Anfangsbuchstabe',
|
||||||
|
letterPlaceholder: 'z. B. A',
|
||||||
|
usagesLabel: 'Herkunftskultur',
|
||||||
|
loadButton: 'Vorschläge laden',
|
||||||
|
loading: 'Lade Vorschläge …',
|
||||||
|
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
|
||||||
|
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
|
||||||
|
close: 'Schließen',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
@@ -112,6 +112,13 @@
|
|||||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hanami von den Kleinen Chaoten",
|
||||||
|
"dob": "10.09.2015",
|
||||||
|
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||||
|
"dateOfDeath": "12.12.2019",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
312
tools/import/extract_docx.py
Normal file
312
tools/import/extract_docx.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""FEAT-8d Stage 1 — Wurfchronik-Detail-Dokument (.docx) extrahieren.
|
||||||
|
|
||||||
|
Liest 'Wurfchronik der Kleinen Chaoten im Detail.docx' (Word/XML, stdlib-Python,
|
||||||
|
kein pip) und erzeugt:
|
||||||
|
output/docx_litters.json — Wurf-Kopfdaten (WS-Code, DOB, Eltern, Notiz)
|
||||||
|
output/docx_animals.json — Tier-Zeilen (Name, Farbe, Abnehmer, ABD, Tod)
|
||||||
|
|
||||||
|
Format der Ausgabe ist so gestaltet, dass ImportDocxService.cs in C# direkt
|
||||||
|
darüber laden kann. Idempotent: mehrfaches Ausführen überschreibt denselben Output.
|
||||||
|
|
||||||
|
Bekannte Sonderwerte im Dokument:
|
||||||
|
ZT = Zucht-Tier (bleibt in Zucht, kein externer Abnehmer)
|
||||||
|
BLEIBT = vorläufig beim Züchter
|
||||||
|
FREI = noch verfügbar
|
||||||
|
VG: = Verpaarungs-Geschichte (bisherige Partner; nicht als Abnehmer werten)
|
||||||
|
RG: = Rückgabe
|
||||||
|
BEW = Bewerbung (Adoptionsinteressent in Prüfung)
|
||||||
|
-- ??? = Platzhalter, kein echter Name
|
||||||
|
|
||||||
|
Feld 'gender': '' = weiblich (kein Marker), '*' auf Farbschlag oder 'G'-Spalte = männlich.
|
||||||
|
|
||||||
|
Ausführung: python extract_docx.py [--docx PFAD]
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_DOCX = os.path.join(
|
||||||
|
r"C:\Users\gulum\dev",
|
||||||
|
"Wurfchronik der Kleinen Chaoten im Detail.docx",
|
||||||
|
)
|
||||||
|
OUT = os.path.join(HERE, "output")
|
||||||
|
|
||||||
|
# --- Regex patterns -------------------------------------------------------
|
||||||
|
|
||||||
|
# Litter header paragraph (after whitespace-collapsing).
|
||||||
|
# Edge cases handled:
|
||||||
|
# - Dual birth date: "*16./17.03.2021"
|
||||||
|
# - WS without numerator: "WS: /5"
|
||||||
|
# - WS with trailing text: "WS: 4/4, davon 1 später..."
|
||||||
|
# - No space before WS: "...ChaotenWS: 2/4"
|
||||||
|
# Date part allows simple DD.MM.YYYY, dual-day (16./17.03.2021), or dual-month (31.05/*01.06.2023).
|
||||||
|
# We capture the LAST complete DD.MM.YYYY in the date token as the birth date.
|
||||||
|
_DATE_TOKEN = r"[\d./\*]+"
|
||||||
|
# Full litter header regex
|
||||||
|
LITTER_RE = re.compile(
|
||||||
|
r"([A-Za-z\d\-]*Wurf)\s*\*\s*(" + _DATE_TOKEN + r")"
|
||||||
|
r"\s*Von:\s*(.+?)\s*&\s*(.+?)\s*WS:\s*(\d*\s*/\s*\d+)"
|
||||||
|
r"(?:[,\s].*?)?(?:Notiz:\s*(.*?))?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Used to extract the canonical date from a date token like "31.05/*01.06.2023"
|
||||||
|
_LAST_DATE_RE = re.compile(r"(\d{1,2}\.\d{2}\.\d{4})(?![\d.])")
|
||||||
|
# Death/adoption date at start of combined T.D column: "16.09.23Tumor am After"
|
||||||
|
DATE_START_RE = re.compile(r"^(\d{1,2}\.\d{1,2}\.\d{2,4})\s*(.*)")
|
||||||
|
# Partner birth date: "Crow (*25.12.20)" or "Tom (*05.01.21)"
|
||||||
|
PARTNER_DOB_RE = re.compile(r"\(\s*\*\s*(\d{2}\.\d{2}\.\d{2,4})\s*\)")
|
||||||
|
# Special-value sentinel names to skip
|
||||||
|
PLACEHOLDER_NAMES = {"--", "???", ""}
|
||||||
|
INTERNAL_TOKENS = {"ZT", "BLEIBT", "FREI", "VG:", "VG*:", "RG:", "BEW"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_dob(d: str) -> str:
|
||||||
|
"""Normalise German date to DD.MM.YYYY."""
|
||||||
|
if not d:
|
||||||
|
return ""
|
||||||
|
p = d.strip().split(".")
|
||||||
|
if len(p) == 3:
|
||||||
|
y = p[2].strip()
|
||||||
|
if len(y) == 2:
|
||||||
|
y = "20" + y
|
||||||
|
return f"{p[0].zfill(2)}.{p[1].zfill(2)}.{y}"
|
||||||
|
return d.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_text(cell_xml: str) -> str:
|
||||||
|
"""Strip XML from a <w:tc> cell and return clean text."""
|
||||||
|
t = re.sub(r"<[^>]+>", "", cell_xml)
|
||||||
|
t = t.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
t = t.replace("'", "'").replace(""", '"')
|
||||||
|
return re.sub(r"\s+", " ", t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal(value: str) -> bool:
|
||||||
|
"""True if the owner/name field holds an internal sentinel, not a real person."""
|
||||||
|
v = value.strip()
|
||||||
|
return v in INTERNAL_TOKENS or any(v.startswith(tok) for tok in INTERNAL_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(docx_path: str):
|
||||||
|
"""Parse the docx and return (litters, animals) lists."""
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
xml = z.read("word/document.xml").decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# ---- Paragraphs → litter header blocks ----
|
||||||
|
paras = re.findall(r"<w:p[ >].*?</w:p>", xml, re.DOTALL)
|
||||||
|
para_texts = []
|
||||||
|
for p in paras:
|
||||||
|
t = re.sub(r"<[^>]+>", "", p)
|
||||||
|
t = t.replace("&", "&").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
if t:
|
||||||
|
para_texts.append(t)
|
||||||
|
|
||||||
|
litters: list[dict] = []
|
||||||
|
current_ws: str = ""
|
||||||
|
current_litter_dob: str = ""
|
||||||
|
|
||||||
|
# Build a WS-code → litter index for assigning animals
|
||||||
|
ws_to_idx: dict[str, int] = {}
|
||||||
|
|
||||||
|
for para in para_texts:
|
||||||
|
m = LITTER_RE.search(para)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
litter_id = m.group(1).strip()
|
||||||
|
dob_raw = m.group(2).strip()
|
||||||
|
mother_raw = m.group(3).strip()
|
||||||
|
father_raw = m.group(4).strip()
|
||||||
|
ws_raw = m.group(5).replace(" ", "")
|
||||||
|
note = (m.group(6) or "").strip()
|
||||||
|
|
||||||
|
# For dual-date tokens like "31.05/*01.06.2023", take the last full date.
|
||||||
|
last_dates = _LAST_DATE_RE.findall(dob_raw)
|
||||||
|
dob_clean = _norm_dob(last_dates[-1] if last_dates else dob_raw)
|
||||||
|
|
||||||
|
litter = {
|
||||||
|
"litterId": litter_id,
|
||||||
|
"dob": dob_clean,
|
||||||
|
"motherName": mother_raw,
|
||||||
|
"fatherName": father_raw,
|
||||||
|
"wsCode": ws_raw,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
ws_to_idx[ws_raw] = len(litters)
|
||||||
|
litters.append(litter)
|
||||||
|
|
||||||
|
# ---- Tables → animal rows ----
|
||||||
|
# Each table sits after a litter-header paragraph; we sequence tables and
|
||||||
|
# litter headers together by their byte offset in the XML.
|
||||||
|
animals: list[dict] = []
|
||||||
|
|
||||||
|
# Build ordered sequence of (offset, type, data) events
|
||||||
|
events: list[tuple[int, str, any]] = []
|
||||||
|
for m in re.finditer(r"<w:p[ >].*?</w:p>", xml, re.DOTALL):
|
||||||
|
t = re.sub(r"<[^>]+>", "", m.group()).replace("&", "&").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
lm = LITTER_RE.search(t)
|
||||||
|
if lm:
|
||||||
|
ws = lm.group(5).replace(" ", "")
|
||||||
|
dob_tok = lm.group(2)
|
||||||
|
last = _LAST_DATE_RE.findall(dob_tok)
|
||||||
|
dob = _norm_dob(last[-1] if last else dob_tok)
|
||||||
|
events.append((m.start(), "litter", (ws, dob)))
|
||||||
|
|
||||||
|
for m in re.finditer(r"<w:tbl[ >].*?</w:tbl>", xml, re.DOTALL):
|
||||||
|
events.append((m.start(), "table", m.group()))
|
||||||
|
|
||||||
|
events.sort(key=lambda e: e[0])
|
||||||
|
|
||||||
|
active_ws = ""
|
||||||
|
active_dob = ""
|
||||||
|
|
||||||
|
for _, etype, edata in events:
|
||||||
|
if etype == "litter":
|
||||||
|
active_ws, active_dob = edata
|
||||||
|
elif etype == "table" and active_ws:
|
||||||
|
# Parse all rows in this table
|
||||||
|
rows = re.findall(r"<w:tr[ >].*?</w:tr>", edata, re.DOTALL)
|
||||||
|
for row in rows:
|
||||||
|
cells_xml = re.findall(r"<w:tc[ >].*?</w:tc>", row, re.DOTALL)
|
||||||
|
ct = [_cell_text(c) for c in cells_xml]
|
||||||
|
if not ct:
|
||||||
|
continue
|
||||||
|
# Skip header rows
|
||||||
|
if ct[0] == "G" and len(ct) > 1 and "Farbe" in ct[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Column positions: G | Farbe | Name | Partner | Abnehmer | ABD | T.D
|
||||||
|
# Some newer tables add ABGew between ABD and T.D (7 or 8 cols)
|
||||||
|
g_col = ct[0] if len(ct) > 0 else ""
|
||||||
|
farbe_raw = ct[1] if len(ct) > 1 else ""
|
||||||
|
name = ct[2] if len(ct) > 2 else ""
|
||||||
|
partner = ct[3] if len(ct) > 3 else ""
|
||||||
|
owner = ct[4] if len(ct) > 4 else ""
|
||||||
|
abd_raw = ct[5] if len(ct) > 5 else ""
|
||||||
|
# If 8 cols, col 6 = ABGew, col 7 = T.D; if 7 cols, col 6 = T.D
|
||||||
|
if len(ct) >= 8:
|
||||||
|
abgew = ct[6]
|
||||||
|
tod_raw = ct[7]
|
||||||
|
elif len(ct) >= 7:
|
||||||
|
abgew = ""
|
||||||
|
tod_raw = ct[6]
|
||||||
|
else:
|
||||||
|
abgew = ""
|
||||||
|
tod_raw = ""
|
||||||
|
|
||||||
|
# Skip placeholders
|
||||||
|
name = name.strip()
|
||||||
|
if name in PLACEHOLDER_NAMES:
|
||||||
|
continue
|
||||||
|
if not farbe_raw.strip() and not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Gender: explicit marker in G column, or * suffix on Farbschlag
|
||||||
|
is_male = bool(g_col.strip() == "*" or farbe_raw.endswith("*"))
|
||||||
|
farbschlag = farbe_raw.rstrip("*").strip()
|
||||||
|
|
||||||
|
# Owner: strip internal sentinels
|
||||||
|
owner_clean = owner.strip()
|
||||||
|
if _is_internal(owner_clean):
|
||||||
|
owner_clean = ""
|
||||||
|
# For multi-owner ("1.) Julia2.) RG:"), take first
|
||||||
|
m1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", owner_clean)
|
||||||
|
if m1:
|
||||||
|
owner_clean = m1.group(1).strip()
|
||||||
|
|
||||||
|
# ABD (Abgabe-Datum)
|
||||||
|
abgabe_date = _norm_dob(abd_raw.strip())
|
||||||
|
|
||||||
|
# T.D column: may start with a date followed by cause
|
||||||
|
death_date = ""
|
||||||
|
death_cause = ""
|
||||||
|
if tod_raw:
|
||||||
|
dm = DATE_START_RE.match(tod_raw.strip())
|
||||||
|
if dm:
|
||||||
|
death_date = _norm_dob(dm.group(1))
|
||||||
|
death_cause = dm.group(2).strip()
|
||||||
|
else:
|
||||||
|
death_cause = tod_raw.strip()
|
||||||
|
|
||||||
|
# Partner name and DOB
|
||||||
|
partner_clean = partner.strip()
|
||||||
|
partner_dob = ""
|
||||||
|
pdob_m = PARTNER_DOB_RE.search(partner_clean)
|
||||||
|
if pdob_m:
|
||||||
|
partner_dob = _norm_dob(pdob_m.group(1))
|
||||||
|
partner_clean = PARTNER_DOB_RE.sub("", partner_clean).strip()
|
||||||
|
# Strip VG:/ZT/etc. prefixes
|
||||||
|
partner_clean = re.sub(r"^(?:VG\*?:|ZT\s*)", "", partner_clean).strip()
|
||||||
|
# Take first partner in numbered list
|
||||||
|
pm1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", partner_clean)
|
||||||
|
if pm1:
|
||||||
|
partner_clean = pm1.group(1).strip()
|
||||||
|
|
||||||
|
animals.append({
|
||||||
|
"wsCode": active_ws,
|
||||||
|
"litterDob": active_dob,
|
||||||
|
"name": name,
|
||||||
|
"farbschlag": farbschlag,
|
||||||
|
"gender": "male" if is_male else "female",
|
||||||
|
"owner": owner_clean,
|
||||||
|
"abgabeDate": abgabe_date,
|
||||||
|
"abgabeWeight": abgew.strip(),
|
||||||
|
"deathDate": death_date,
|
||||||
|
"deathCause": death_cause,
|
||||||
|
"partnerName": partner_clean,
|
||||||
|
"partnerDob": partner_dob,
|
||||||
|
})
|
||||||
|
|
||||||
|
return litters, animals
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="FEAT-8d docx extractor")
|
||||||
|
ap.add_argument("--docx", default=DEFAULT_DOCX,
|
||||||
|
help="Pfad zur 'im Detail.docx'")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isfile(args.docx):
|
||||||
|
print(f"Fehler: Datei nicht gefunden: {args.docx}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Lese: {args.docx}")
|
||||||
|
litters, animals = extract(args.docx)
|
||||||
|
|
||||||
|
litters_path = os.path.join(OUT, "docx_litters.json")
|
||||||
|
animals_path = os.path.join(OUT, "docx_animals.json")
|
||||||
|
|
||||||
|
with open(litters_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(litters, f, ensure_ascii=False, indent=2)
|
||||||
|
with open(animals_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(animals, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
named = sum(1 for a in animals if a["name"])
|
||||||
|
with_owner = sum(1 for a in animals if a["owner"])
|
||||||
|
with_death = sum(1 for a in animals if a["deathDate"])
|
||||||
|
with_abgabe = sum(1 for a in animals if a["abgabeDate"])
|
||||||
|
|
||||||
|
print(f"Würfe: {len(litters)}")
|
||||||
|
print(f"Tiere: {len(animals)} (benannt: {named})")
|
||||||
|
print(f" mit Abnehmer: {with_owner}")
|
||||||
|
print(f" mit Abgabe-Dat: {with_abgabe}")
|
||||||
|
print(f" mit Tod-Datum: {with_death}")
|
||||||
|
print(f"Ausgabe: {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
77
tools/import/test_extract_docx.py
Normal file
77
tools/import/test_extract_docx.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for extract_docx.py — run: python test_extract_docx.py"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Require the docx to exist; skip if not present (CI won't have it)
|
||||||
|
DOCX = os.path.join(r"C:\Users\gulum\dev",
|
||||||
|
"Wurfchronik der Kleinen Chaoten im Detail.docx")
|
||||||
|
SKIP = not os.path.isfile(DOCX)
|
||||||
|
|
||||||
|
import extract_docx as ed
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
global failed
|
||||||
|
print(("ok: " if cond else "FAIL: ") + name)
|
||||||
|
if not cond:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- _norm_dob ---
|
||||||
|
check("norm_dob 2-digit year", ed._norm_dob("12.09.21") == "12.09.2021")
|
||||||
|
check("norm_dob 4-digit year", ed._norm_dob("07.04.2019") == "07.04.2019")
|
||||||
|
check("norm_dob empty", ed._norm_dob("") == "")
|
||||||
|
|
||||||
|
# --- _LAST_DATE_RE ---
|
||||||
|
check("last date: simple", ed._LAST_DATE_RE.findall("07.04.2019") == ["07.04.2019"])
|
||||||
|
check("last date: dual-day", ed._LAST_DATE_RE.findall("16./17.03.2021") == ["17.03.2021"])
|
||||||
|
check("last date: dual-month", ed._LAST_DATE_RE.findall("31.05/*01.06.2023") == ["01.06.2023"])
|
||||||
|
|
||||||
|
# --- _is_internal ---
|
||||||
|
check("ZT is internal", ed._is_internal("ZT"))
|
||||||
|
check("BLEIBT is internal", ed._is_internal("BLEIBT"))
|
||||||
|
check("VG: is internal", ed._is_internal("VG: Partner"))
|
||||||
|
check("real name not internal", not ed._is_internal("Marion Teichmann"))
|
||||||
|
|
||||||
|
# --- LITTER_RE ---
|
||||||
|
cases = [
|
||||||
|
("D19-Wurf *07.04.2019Von: Xhemile gen. Chanel v.d. Kleinen Chaoten & Omero v.d. Kleinen Chaoten WS: 2/4Notiz:", "2/4", "07.04.2019"),
|
||||||
|
("-Wurf *16./17.03.2021Von: Victoria Welby v.d. Kleinen Chaoten & Patch v.d. Kleinen Chaoten WS: /5Notiz:", "/5", "16./17.03.2021"),
|
||||||
|
("S22-Wurf *31.05/*01.06.2023Von: Velvet v.d. Kleinen Chaoten & Vance Sohn v.d. Kleinen ChaotenWS: 3/3", "3/3", "31.05/*01.06.2023"),
|
||||||
|
("Q21-Wurf *21.03.2022Von: Belica gen. Emi v.d. Kleinen Chaoten & Zac gen. Action v.d. Kleinen ChaotenWS: 2/4Notiz:", "2/4", "21.03.2022"),
|
||||||
|
]
|
||||||
|
for para, expected_ws, _ in cases:
|
||||||
|
m = ed.LITTER_RE.search(para)
|
||||||
|
ws = m.group(5).replace(" ", "") if m else None
|
||||||
|
check(f"LITTER_RE matches: {para[:50]}...", ws == expected_ws)
|
||||||
|
|
||||||
|
if SKIP:
|
||||||
|
print("(Skipping live-docx tests: file not found)")
|
||||||
|
else:
|
||||||
|
litters, animals = ed.extract(DOCX)
|
||||||
|
check("93 litters extracted", len(litters) == 93)
|
||||||
|
check("All litters have wsCode", all(l["wsCode"] for l in litters))
|
||||||
|
check("All litters have dob", all(l["dob"] for l in litters))
|
||||||
|
check(">200 named animals", len(animals) >= 200)
|
||||||
|
check(">150 animals with owner", sum(1 for a in animals if a["owner"]) >= 150)
|
||||||
|
check(">20 animals with death date", sum(1 for a in animals if a["deathDate"]) >= 20)
|
||||||
|
# Verify first litter
|
||||||
|
d19 = next((l for l in litters if l["litterId"] == "D19-Wurf"), None)
|
||||||
|
check("D19-Wurf found", d19 is not None)
|
||||||
|
check("D19-Wurf dob correct", d19 and d19["dob"] == "07.04.2019")
|
||||||
|
check("D19-Wurf wsCode = 2/4", d19 and d19["wsCode"] == "2/4")
|
||||||
|
check("D19-Wurf mother contains Xhemile", d19 and "Xhemile" in d19["motherName"])
|
||||||
|
# Verify Eddie in animals
|
||||||
|
eddie = next((a for a in animals if a["name"] == "Eddie" and a["wsCode"] == "2/4"), None)
|
||||||
|
check("Eddie found in D19-Wurf", eddie is not None)
|
||||||
|
check("Eddie gender=male (Zobel* suffix)", eddie and eddie["gender"] == "male")
|
||||||
|
check("Eddie abgabeDate", eddie and eddie["abgabeDate"] == "12.09.2021")
|
||||||
|
# Flash death date
|
||||||
|
flash = next((a for a in animals if a["name"] == "Flash" and a["wsCode"] == "3/3"), None)
|
||||||
|
check("Flash death date extracted", flash and flash["deathDate"] == "16.09.2023")
|
||||||
|
check("Flash death cause extracted", flash and "Tumor" in flash["deathCause"])
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"\n{failed} test(s) FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nALL PASS")
|
||||||
Reference in New Issue
Block a user