AI pedigree scanning, reusable ancestors, rabbit merge and delete

Pedigree scanning
- Replace the Tesseract OCR pipeline with server-side Claude vision
  (claude-opus-5-5, structured output) that reads any layout, printed or
  handwritten, into the subject + 14 ancestors with per-field uncertainty
- New POST/GET /api/pedigree-scan; enabled by ANTHROPIC_API_KEY in .env
- Add Rabbit step 1: "Scan a pedigree" card, photo review panel, "Check"
  badges on uncertain ancestors, sire/dam herd matching
- Remove vendored Tesseract assets and OCR libs; bump SW cache to v2
- nginx /api read timeout raised to 180s for slow scans

Ancestors
- Manual ancestors default to 3 lbs and Unknown DOB (legacy 0 lbs read as
  3); weight/DOB shown in the tree and on Review
- On save, ancestors entered as text become linked rabbit records
  (status Other), deduped by ear number or name + farm

Existing rabbits
- Adding a rabbit that already exists tops up the existing record: fills
  empty fields, adds missing ancestors, promotes reference ancestors; never
  overwrites. Review step warns beforehand
- Delete button on rabbit detail; delete snapshots full ancestor JSON to
  offspring, clears litter links and timeline events, and is blocked while
  the rabbit has show entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-28 14:29:06 -04:00
parent 5d29e2b370
commit 990c703bb2
42 changed files with 1669 additions and 1847 deletions

View File

@ -65,7 +65,7 @@ There are only two core tables — no separate "Kit" table:
Kits are full Rabbit records from birth, linked to a litter implicitly via matching `sire_id`/`dam_id`/DOB — the Litter detail page derives its kit list by querying Rabbits rather than a join table. When adding features touching litters or kits, preserve this derivation rather than introducing a Kit entity.
Ancestor pedigree nodes that aren't in the DB are stored as freeform text rather than null FKs, so entry is never blocked by incomplete ancestor records.
Ancestor pedigree nodes that aren't in the DB are entered as freeform text (manual-ancestor JSON) rather than null FKs, so entry is never blocked by incomplete ancestor records. On rabbit creation the server then materializes those entries into real, linked Rabbit records (status `other`, deduped by ear number or name + farm — `server/lib/ancestors.ts`) so ancestors are reusable; text columns remain only for branches above an Unknown parent and for legacy data.
Weight is stored as a decimal float in lbs internally; lb+oz vs decimal display is a per-user setting applied at the read/write boundary, not stored separately.

View File

@ -1,5 +1,7 @@
# MyKindlebox — OCR Pedigree Scanning Feature
> **Superseded 2026-10-28 — AI vision replaces Tesseract.** Position-based OCR could not handle the real variety of pedigrees (horizontal vs vertical, color vs B&W, handwritten vs printed, every breeder's own format). Per the user's decisions, scanning now sends the photo from the server to the **Claude API** (`claude-opus-5-5`), which returns the subject + all 14 ancestors as schema-validated JSON with per-field "uncertain" flags. Tesseract, the vendored `/tesseract/` assets, the `lib/ocr*` modules, the zone-picker plan, and the "no LLM / fully client-side / offline" constraint are **dropped**. The photo is **not stored**. Scanning needs internet and `ANTHROPIC_API_KEY` on the server; without it the button is hidden. Still in force from this doc: the Add Rabbit Step 1 integration point, Q2/Q3/Q6 (sire/dam herd matching: exact ear number, else unique exact name; a matched branch locks to the herd record; everything else lands as editable manual nodes), and Q4 normalization (decimal lbs, YYYY-MM-DD). See `architecture.md` → `lib/pedigreeScan.ts`, `routes/pedigreeScan.ts`, `PedigreeScan.jsx`. The rest of this document is historical.
**Feature:** OCR-assisted pedigree entry with manual zone correction fallback
**Stack:** Node.js / React / SQLite
**Packages:** `tesseract.js`, `jimp`, `react-image-crop`

View File

@ -1,5 +1,7 @@
# MyKindlebox — OCR Pedigree Scanning: Implementation Plan
> **Superseded 2026-10-28.** This Tesseract plan was abandoned after Phase 3 in favor of server-side Claude vision extraction — see the note at the top of `FEATURE_OCR_Pedigree_Scan.md`. Kept for history only.
**Feature:** OCR-assisted pedigree entry with manual zone correction fallback
**Reference:** `FEATURE_OCR_Pedigree_Scan.md`, `architecture.md`
**Scope:** Base feature only — image capture, OCR pipeline, form pre-fill, zone picker correction.

View File

@ -35,6 +35,9 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `routes/settings.ts` | Express `Router` mounted at `/api/settings`. `GET /` flattens the `settings` table into a single object; `PATCH /` rejects any request containing a key outside the `rabbitry_name`/`weight_unit` allowlist. No casing conversion needed — settings keys are stored and read as snake_case directly. |
| `routes/stats.ts` | Express `Router` mounted at `/api/stats`. Returns dashboard counts (`herd`, `bucks`, `does`, `kits`, `for_sale`, `on_hold`, `sold`, `archived`) computed live from `rabbits` on every request — no cached counters. `kits` reuses the same 56-day `dob` window as the rabbits list's `sex=kits` filter; `archived` sums `status IN ('other', 'deceased')`. |
| `routes/import.ts` | Express `Router` mounted at `/api/import` (behind `requireAuth`). `POST /` accepts a single multipart `file` (multer **memory** storage, 10 MB cap) and **replaces the entire herd**: it wipes `litters` then `rabbits` and inserts the file's rabbits, all inside one `db.transaction` so a parse/insert failure rolls back and leaves existing data untouched. Auto-detects three shapes: **legacy CSV** (the old PHP Kindlebox export — header has `ID`/`GENDER`/`SIRE_ID`), **native CSV** (snake_case headers matching the API), and **JSON** (a rabbit array or `{ rabbits: [...] }`). Legacy mapping: `GENDER` 1→buck/0→doe; numeric `STATUS` via `LEGACY_STATUS_MAP` (0 Herd→active, 1 For Sale→for_sale, 2 Pending Sale→on_hold, 3 Sold→sold, 4 Other→other, 5 Dead→deceased — sourced from the original `editrabbit.php` status `<select>`); `"0"`/blank sentinels for ear/registration numbers → null; `WEIGHT` of 0 → null (unweighed); gene columns → `locus_*`. Parentage is preserved by importing **original ids** and a **two-pass insert** (insert all rows with null `sire_id`/`dam_id`, then set the FK only when it points at an id actually present in the file — legacy `9999` "unknown parent" sentinels and references to non-imported rabbits become null). Includes a hand-rolled RFC-4180-ish `parseCsv` (quoted fields, `""` escapes, CR/LF, BOM strip) rather than adding a CSV dependency, matching the project's dependency-light ethos. Returns `{ format, rabbits_imported }`. **Scope is rabbits only** (per user decision) — litters are wiped but not reconstructed from the legacy `LITTER_ID` column. Rejects non-UTF-8 input early (`file.buffer.includes(0)` — a NUL byte means a UTF-16 Excel "Unicode Text" export or a binary `.xlsx`/image) with an actionable message, since decoding such a file as UTF-8 would otherwise import garbage/phantom rows. The Settings page also pre-validates extension + 10 MB size **before** uploading, because an over-limit file is rejected by multer mid-stream and the browser surfaces that as an opaque "network error" rather than the server's 400. |
| `lib/pedigreeScan.ts` | Pedigree scanning via Claude vision (`claude-opus-5-5`, effort `high`, `fallbacks: "default"` under beta `server-side-fallback-2026-07-01`). `scanPedigreeImage(buffer, mediaType)` sends the photo with a system prompt describing pedigree conventions (horizontal/vertical/boxed layouts, labels, color coding, farm-prefix splitting, unit/date normalization, "never invent; mark uncertain") and a nested Zod schema (subject → sire/dam → grandparents → great-grandparents, each `{name, farm, ear_number, dob, weight_lbs, uncertain[]}`, subject also `sex/breed/color`) parsed via `client.beta.messages.parse` + `betaZodOutputFormat`. Output is cleaned (trim, ISO-date validation, weight sanity range, uppercase ear #) and flattened to the client's 14 slot names. Typed SDK errors, refusals and non-pedigree images map to `PedigreeScanError(message, status)`. Enabled only when `ANTHROPIC_API_KEY` is set. |
| `routes/pedigreeScan.ts` | `GET /api/pedigree-scan` → `{ enabled }`; `POST /api/pedigree-scan` (multipart `image`, in-memory multer, 10 MB, JPEG/PNG/WebP) → scan result plus `matches: {sire, dam}` from `findHerdMatch` (exact ear number, else exactly one exact case-insensitive name; otherwise null). The image is never written to disk or stored. |
| `lib/ancestors.ts` | Ancestor materialization + "add again = top up". `POST /api/rabbits` runs in one transaction: `findExistingSubject` (same ear number, else same name + farm; never when ear #, sex or DOB contradict) → if found, `mergeIntoExisting` fills only EMPTY columns (never overwrites), promotes a status-`other` reference record to the submitted status, links a herd-picked parent if it had none, and `attachMissingParents` walks the entered pedigree adding/topping-up ancestors (an existing parent that is a different animal wins and is reported in `conflicts`); response is the row + `merged` report (200). Otherwise `materializeAncestors` turns each text branch into linked status-`other` rabbits (parents first, reusing matches via `findExisting`, which prefers a non-`other` rabbit when ear numbers are shared) and clears that branch's text columns (201, `merged: null`). `POST /api/rabbits/find-existing` exposes the same subject match for the Review step banner. Tests: `lib/ancestors.test.ts` (temp DATA_DIR). PATCH and import don't materialize. |
| `utils/case.ts` | `toSnakeCase`/`toCamelCase` — generic recursive key-casing converters used at the API boundary. See casing convention below. |
**Why TypeScript:** the implementation plan specifies `db/client.ts` for the Drizzle setup. The server started as plain CommonJS JS in Step 1.2; it was converted to TypeScript at Step 2.1 specifically so the schema/DB layer has type safety, per explicit user decision (plain JS was the lower-effort alternative but not chosen).
@ -59,7 +62,6 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
| `src/lib/ocrPreprocess.js` | OCR Pedigree Scanning, Phase 2. Canvas-only (no `jimp`) image normalization for OCR. Exports `preprocessImage(file)` — decode → downscale to `MAX_WIDTH` (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG `Blob`. Also exports the tunable `MAX_WIDTH` and `BINARIZE_THRESHOLD` (binarization cutoff, tuned to **103** during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. **Responsive layout:** at `≥1280px` (`useMediaQuery`, `hooks/useMediaQuery.js`) it renders the 3-column tree; below that the same `AncestorSlot`s render as a stacked, indented list (`StackedBranch`) in "Sire's side"/"Dam's side" cards, so phones never scroll sideways. Popup drop-direction accounts for the bottom chrome (action bar + mobile nav). |
@ -67,6 +69,9 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. Optional `onUseColor(color)` adds a "Use as color name" button (Add + Edit wire it to `details.color_name`). |
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save. Formats values for display (sex/status labels, `formatDate`, `formatWeight` in the user's unit), hides empty rows (Sire/Dam always shown; deeper ancestors only when set) and shows genetics as "Not set" when empty. Each section has an Edit button calling `onEdit(step)`. Action buttons live in `AddRabbit`'s `StickyActionBar`, not here. |
| `src/components/StickyActionBar.jsx` | Bottom-pinned form action row (sticky above the mobile bottom nav, `bottom-0` on desktop). Used by the Add Rabbit flow for Back/Next/Save. |
| `src/components/PedigreeScan.jsx` | Step 1 "Scan a pedigree" card (shown only when the server reports scanning enabled). Downscales the photo (`lib/imageResize.js`), posts it, shows a 20–60s progress state, then a review card: herd links, a "Double-check these" list built from each slot's `uncertain` fields, model notes, "Scan again", and a collapsible view of the photo (object URL only). |
| `src/lib/imageResize.js` | `downscaleImage(file, maxLongEdge=2400)` → JPEG Blob via `createImageBitmap(..., {imageOrientation: "from-image"})` + canvas. |
| `src/lib/pedigree.js` (scan helpers) | `pedigreeStateFromScan(result)` → PedigreeTree state (matched sire/dam `linked`, other named ancestors `manual` JSON) and `uncertainScanSlots(result)` → Set of slots to highlight. `AddRabbit` remounts the tree with `key={pedigreeKey}` + `initialState={pedigreeSeed}` on each scan, merges the subject into `details` (non-null values only), and passes `uncertainSlots`; `PedigreeTree` shows a "Check" badge on each until the user edits that slot. |
| `src/pages/AddRabbit.jsx` (flow notes) | The current step is a `?step=` URL param (pushed, so the Back gesture moves between steps; step 3 falls back to 2 without a name). All steps stay mounted and are hidden with CSS so `PedigreeTree`'s internal state survives going back / Review "Edit". Step 1 has an optional name field feeding the tree's root label. A `beforeunload` guard warns on reload/close with unsaved input (in-app link navigation is not guarded — `BrowserRouter` has no `useBlocker`). On save, a failed photo upload still navigates to the created rabbit with a `location.state.notice` (shown by `RabbitDetail`) instead of leaving the user on Review, where re-saving would create a duplicate. |
| `src/lib/theme.js` | Theme registry (post-Phase-11 feature) — the single source of truth for the *list* of selectable themes (`classic`/`midnight`/`ocean`/`berry`/`sunset`/`carnival`), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome `theme-color`. The actual palettes are CSS variables in `index.css` (one `[data-theme='…']` block per theme); this module only knows ids + preview metadata. `applyTheme(id)` sets `document.documentElement.dataset.theme` (which swaps the variables), updates the `theme-color` meta, and mirrors the id to `localStorage` (key `mykindlebox-theme`). `normalizeTheme` falls back to `classic` for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below. |
| `src/lib/breeds.js` | The ARBA-recognized rabbit breed list (`ARBA_BREEDS`) powering the breed picker, plus `isCustomBreed(breed)` (true for a non-empty value not on the list). Suggestion list only — `BreedSelect` keeps a free-text fallback, so a breed not on this list still saves. Add new ARBA breeds here. |
@ -75,8 +80,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `<link>` tags, applies `font-body` to `<body>` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v1`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. **OCR addition:** the five vendored `/tesseract/` assets (see `public/tesseract/` below) are precached on `install` (a `TESSERACT_URLS` list alongside the existing `SHELL_URLS`, same cache) and served **cache-first** via a dedicated `fetch`-handler branch — they're large/static/versioned, so once stored they're served straight from cache with no network round-trip (the rest of the shell stays network-first). See the OCR-assets convention note below. |
| `public/tesseract/` | Vendored Tesseract OCR runtime (OCR Phase 1 Step 1.2), served from the app's own origin so the pedigree scanner makes **no CDN calls** and works offline. Five files: `worker.min.js` (the tesseract.js web-worker script); `eng.traineddata` (the **uncompressed** English LSTM language data); and three feature-detect WASM core builds — `tesseract-core-lstm.wasm.js`, `tesseract-core-simd-lstm.wasm.js`, `tesseract-core-relaxedsimd-lstm.wasm.js` (each ~3.9 MB, self-contained — the WASM is base64-embedded in the `.wasm.js`). At runtime tesseract.js (default OEM `LSTM_ONLY`) feature-detects and loads exactly **one** of the three cores; all three are vendored so a directory-based `corePath` auto-selects correctly on any browser. See the OCR-assets convention note below. |
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v2`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. The cache name was bumped v1→v2 when the vendored Tesseract assets were removed (2026-10-28) so `activate` deletes installed clients' old ~16 MB precache. |
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
| `Dockerfile` | Multi-stage production build: a Node 20 Alpine stage runs `npm ci && npm run build`, then an nginx 1.27 Alpine stage serves the built `dist/` with `nginx.conf` (which reverse-proxies `/api` + `/uploads` to the server container and serves the SPA fallback + `sw.js` no-cache header). The Vite dev server is never run in Docker. |
@ -110,6 +114,6 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
- **OCR/Tesseract assets are vendored, not CDN-fetched, and the `eng.traineddata` is stored uncompressed** (OCR Pedigree Scanning feature, `public/tesseract/`): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives under `public/tesseract/` and the worker must be configured with explicit local paths. Two load-bearing facts when wiring up `ocrRun.js` (OCR Phase 3): (1) **`gzip: false` is required** alongside `langPath: '/tesseract/'`, because the vendored `eng.traineddata` is decompressed — the default `gzip: true` makes the worker request `eng.traineddata.gz`, which 404s. (2) **`corePath` should point at the directory `'/tesseract/'`, not a specific file**, so tesseract.js's `wasm-feature-detect` step picks the matching core (`relaxedsimd-lstm` → `simd-lstm` → `lstm`) for the running browser; all three `-lstm` `.wasm.js` cores are vendored for exactly this reason (the app uses the default `LSTM_ONLY` OEM, so the non-`-lstm`/`.asm` cores are never requested and aren't vendored). `workerPath` is `'/tesseract/worker.min.js'`. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch in `tesseract.js/src/worker-script/browser/getCore.js`. The service worker precaches + cache-firsts these (see `public/sw.js` above).
- **Auth is account-based and always on** (`FEATURE_User_Accounts.md`, replacing the original optional shared-password mode): accounts live in `users`, sessions in `auth_sessions` (so restarts don't sign anyone out), passwords are scrypt-hashed via `node:crypto` (**deliberately not bcrypt/argon2** — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created through `POST /api/auth/setup`, which only works while the `users` table is empty — the client's `AuthProvider` shows a `SetupScreen` when `GET /api/auth/status` reports `configured: false`. Any signed-in user manages users at `/api/users` (Settings → Users card); you can't delete yourself, which transitively protects the last account. **`/api/settings` kept its exact single-object shape** but storage split: `theme`/`weight_unit` read/write the caller's `users` row (NULL falls back to the global settings value), the rest stay in the `settings` table — client consumers (ThemeSync, `useWeightUnit`, the Settings page) required no changes. The old `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` env var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping.
- **Deleting a rabbit** (`DELETE /api/rabbits/:id`, Delete button on Rabbit Detail): refused with 409 while it has show entries (history the user would lose; Archive is the alternative). Otherwise, in one transaction: offspring get a manual-ancestor JSON snapshot of it and the two generations above (full name/farm/ear #/weight/DOB, so it can be re-matched later), litters null that parent id, and its `rabbit_events` are deleted — SQLite FK enforcement is off, so nothing cascades by itself.
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.

View File

@ -301,6 +301,14 @@ All four modules are pure client-side libs under `client/src/lib/` with no serve
- **Validation so far**: `npm test` — 34 pass / 0 fail (15 new across the two test files). `npx eslint` clean on all six new files. `npm run build` succeeds (the new `tesseract.js` import resolves; it isn't pulled into the app bundle yet since nothing imports the pipeline until plan Phase 5). **Per the user's request, stopping here for them to validate the Phase 3 tests (especially the browser-only `runOCR`/`extractPedigree` paths against a real pedigree image) before OCR Phase 4 — the UI components — begins.**
## OCR → AI vision pivot (2026-10-28)
Tesseract phases 1–3 above are **superseded**. User decisions: Claude API (not a local model), `claude-opus-5-5`, remove Tesseract entirely, don't store the scanned photo.
- Removed: `client/public/tesseract/` (~16 MB), `lib/ocr*.js` + tests, `pages/OcrDev.jsx` + `/ocr-dev` route, `tesseract.js` / `react-image-crop` deps; service-worker cache bumped to `mykindlebox-shell-v2` so installed clients purge the old precache.
- Added server `lib/pedigreeScan.ts` + `routes/pedigreeScan.ts` (`@anthropic-ai/sdk`, `zod`), `ANTHROPIC_API_KEY` passthrough in docker-compose, nginx `proxy_read_timeout 180s` for `/api/`.
- Added client `PedigreeScan.jsx`, `lib/imageResize.js`, scan helpers in `lib/pedigree.js` (+2 tests), "Check" highlighting in `PedigreeTree`, wiring in `AddRabbit.jsx`.
- Validated: client lint / test (21 pass) / build; server `tsc`; local smoke test of `GET`/`POST /api/pedigree-scan` with no key (disabled → 503), a wrong file type (400), and an invalid key (reaches Anthropic → 502 "key rejected"). **Pending:** end-to-end scans of real pedigrees with a real key, and prompt tuning on a varied sample.
## User Accounts & Onboarding (2026-07-02)
Replaced the optional shared-password auth with real user accounts — see

View File

@ -145,6 +145,21 @@ Finally, hard-refresh the browser (Ctrl+Shift+R). On an installed phone PWA,
fully close and reopen the app to pick up the new build. Once everything looks
right, the `data-backup-*` folder can be deleted.
### Pedigree scanning (optional)
**Add Rabbit → Scan a pedigree** reads a photo of a paper pedigree (any layout,
printed or handwritten) and fills in the tree. It uses the Claude API, so it
needs an API key from [console.anthropic.com](https://console.anthropic.com) and
internet access on the server. Add it to `.env` next to `docker-compose.yml`:
```env
ANTHROPIC_API_KEY=sk-ant-...
```
Then run `docker compose up --build -d`. Without a key the scan button is hidden
and everything else works as before. Each scan costs roughly 5–10¢ in API usage.
The photo is sent to Anthropic for the scan and is not stored by MyKindlebox.
### Authentication
The app uses **user accounts** — everyone shares the same herd; accounts are

View File

@ -16,6 +16,9 @@ server {
# "server" is the service name from docker-compose.yml.
location /api/ {
proxy_pass http://server:3001;
# Pedigree scans wait on an AI model and can take a minute or more; the
# default 60s read timeout would cut them off with a 504.
proxy_read_timeout 180s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -11,9 +11,7 @@
"@tanstack/react-query": "^5.101.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-image-crop": "^11.1.2",
"react-router-dom": "^7.18.0",
"tesseract.js": "7.0.0"
"react-router-dom": "^7.18.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -1168,12 +1166,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@ -1890,12 +1882,6 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/idb-keyval": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz",
"integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -1978,12 +1964,6 @@
"node": ">=0.12.0"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -2496,26 +2476,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@ -2556,15 +2516,6 @@
"node": ">= 6"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -2907,15 +2858,6 @@
"react": "^19.2.7"
}
},
"node_modules/react-image-crop": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
@ -2990,12 +2932,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -3216,30 +3152,6 @@
"node": ">=14.0.0"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -3293,12 +3205,6 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -3453,28 +3359,6 @@
}
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -3521,15 +3405,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",

View File

@ -14,9 +14,7 @@
"@tanstack/react-query": "^5.101.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-image-crop": "^11.1.2",
"react-router-dom": "^7.18.0",
"tesseract.js": "7.0.0"
"react-router-dom": "^7.18.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View File

@ -1,40 +1,11 @@
const CACHE_NAME = 'mykindlebox-shell-v1'
// Bumped from v1 when the ~16 MB vendored Tesseract OCR assets were removed:
// the activate handler deletes every cache whose name isn't CACHE_NAME, so
// installed clients drop the old precached files on their next update.
const CACHE_NAME = 'mykindlebox-shell-v2'
const SHELL_URLS = ['/', '/favicon.svg', '/icon-192.png', '/manifest.json']
// Vendored Tesseract OCR runtime: the worker, the three feature-detect WASM core
// variants (relaxedSIMD / SIMD / plain LSTM — only one is loaded per browser, but
// we can't know which ahead of time, so all are cached), and the English language
// data. These are large (~16 MB total) but static and versioned with the app, so
// they're precached on install and served cache-first (see the fetch handler):
// once stored they never need re-fetching, and the pedigree scanner must work
// fully offline. See FEATURE_OCR_Pedigree_Scan.md Q7.
//
// UPGRADE CONTRACT: cache-first means installed clients serve these from cache
// forever. If tesseract.js is ever upgraded (package.json pins the exact version
// for this reason) the files under public/tesseract/ must be re-vendored AND
// CACHE_NAME must be bumped, or existing clients keep the old worker/WASM while
// the app bundle uses the new library.
const TESSERACT_URLS = [
'/tesseract/worker.min.js',
'/tesseract/tesseract-core-lstm.wasm.js',
'/tesseract/tesseract-core-simd-lstm.wasm.js',
'/tesseract/tesseract-core-relaxedsimd-lstm.wasm.js',
'/tesseract/eng.traineddata',
]
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) =>
Promise.all([
cache.addAll(SHELL_URLS),
// The ~16 MB Tesseract batch is best-effort: if it fails (flaky first
// load, missing file in a bad deploy), the shell must still install —
// the cache-first fetch branch below backfills these lazily the next
// time the scanner runs online.
cache.addAll(TESSERACT_URLS).catch(() => {}),
]),
),
)
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)))
})
self.addEventListener('activate', (event) => {
@ -57,26 +28,6 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url)
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/uploads/')) return
// Tesseract assets are static and large — cache-first. Serve the stored copy
// outright when present (no network round-trip, works offline), and only hit
// the network on a cache miss, storing the result for next time.
if (url.pathname.startsWith('/tesseract/')) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ??
fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy))
}
return response
}),
),
)
return
}
event.respondWith(
fetch(request)
.then((response) => {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -22,7 +22,6 @@ import ShowEvents from './pages/ShowEvents'
import ShowEventDetail from './pages/ShowEventDetail'
import Settings from './pages/Settings'
import NotFound from './pages/NotFound'
import OcrDev from './pages/OcrDev' // TEMP: OCR Phase 3 validation harness — remove with its route
const queryClient = new QueryClient()
@ -49,8 +48,6 @@ function App() {
{/* Standalone print route — deliberately outside <Layout> so the
sidebar/bottom-nav don't render (or print) on the pedigree slip. */}
<Route path="/herd/:id/pedigree" element={<PedigreePrint />} />
{/* TEMP: OCR Phase 3 validation harness — remove with its import once validated. */}
<Route path="/ocr-dev" element={<OcrDev />} />
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/herd" element={<Herd />} />

View File

@ -59,6 +59,10 @@ export const createRabbit = (data) =>
request('/rabbits', { method: 'POST', body: JSON.stringify(data) })
export const updateRabbit = (id, data) =>
request(`/rabbits/${id}`, { method: 'PATCH', body: JSON.stringify(data) })
// Which existing rabbit an Add Rabbit submission would top up (same matching
// as POST /rabbits), or null when it would be created new.
export const findExistingRabbit = (data) =>
request('/rabbits/find-existing', { method: 'POST', body: JSON.stringify(data) })
export const deleteRabbit = (id) => request(`/rabbits/${id}`, { method: 'DELETE' })
export const uploadRabbitPhoto = (id, file) => {
const formData = new FormData()
@ -66,6 +70,15 @@ export const uploadRabbitPhoto = (id, file) => {
return request(`/rabbits/${id}/photo`, { method: 'POST', body: formData })
}
// Pedigree scanning (AI vision on the server; see server/lib/pedigreeScan.ts).
// `getPedigreeScanStatus` → { enabled } decides whether the scan button shows.
export const getPedigreeScanStatus = () => request('/pedigree-scan')
export const scanPedigree = (imageBlob) => {
const formData = new FormData()
formData.append('image', imageBlob, 'pedigree.jpg')
return request('/pedigree-scan', { method: 'POST', body: formData })
}
// Litters
export const getLitters = () => request('/litters')
export const getLitter = (id) => request(`/litters/${id}`)

View File

@ -0,0 +1,198 @@
import { useRef, useState } from 'react'
import Button from './Button'
import Card from './Card'
import { scanPedigree } from '../api'
import { downscaleImage } from '../lib/imageResize'
import { ALL_SLOTS } from '../lib/pedigree'
const FIELD_LABELS = {
name: 'name',
farm: 'farm',
ear_number: 'ear number',
dob: 'date of birth',
weight_lbs: 'weight',
}
function Spinner() {
return (
<svg className="h-5 w-5 animate-spin text-accent" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
)
}
// "Sire's Dam: ear number, date of birth" lines for everything the scan was
// unsure about, so the user knows exactly what to double-check.
function uncertaintyLines(result) {
const lines = []
if (result.subject?.uncertain?.length) {
lines.push(`This rabbit: ${result.subject.uncertain.map((f) => FIELD_LABELS[f]).join(', ')}`)
}
for (const { slot, label } of ALL_SLOTS) {
const fields = result.ancestors?.[slot]?.uncertain
if (!result.matches?.[slot] && fields?.length) {
lines.push(`${label}: ${fields.map((f) => FIELD_LABELS[f]).join(', ')}`)
}
}
return lines
}
// Step 1 entry point for scanning a paper pedigree. Before a scan it offers
// the button; afterwards it shows the photo (for checking the filled-in tree
// against) and what the scan flagged as uncertain. The parent owns applying
// the result to the form via `onScanned(result, imageUrl)`.
function PedigreeScan({ result, imageUrl, onScanned, confirmReplace }) {
const inputRef = useRef(null)
const [scanning, setScanning] = useState(false)
const [error, setError] = useState(null)
const [photoOpen, setPhotoOpen] = useState(true)
async function handleFile(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
if (confirmReplace && !confirmReplace()) return
setScanning(true)
setError(null)
try {
let image
try {
image = await downscaleImage(file)
} catch {
throw new Error('Couldn’t read that photo. Try a JPEG or PNG.')
}
const scan = await scanPedigree(image)
onScanned(scan, URL.createObjectURL(image))
setPhotoOpen(true)
} catch (err) {
setError(err.message || 'The scan failed. Try again.')
} finally {
setScanning(false)
}
}
const fileInput = (
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFile}
className="hidden"
aria-hidden="true"
tabIndex={-1}
/>
)
if (scanning) {
return (
<Card className="flex items-center gap-3 p-4" role="status">
<Spinner />
<div>
<p className="font-body text-sm font-medium text-text-primary">Reading the pedigree…</p>
<p className="font-body text-xs text-text-secondary">This usually takes 20–60 seconds. Keep this page open.</p>
</div>
</Card>
)
}
if (!result) {
return (
<Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
{fileInput}
<div>
<p className="font-body text-sm font-medium text-text-primary">Have a paper pedigree?</p>
<p className="font-body text-sm text-text-secondary">
Take or upload a photo and the tree fills itself in. Printed or handwritten, any layout.
</p>
{error && (
<p role="alert" className="mt-2 font-body text-sm text-danger">
{error}
</p>
)}
</div>
<Button type="button" variant="secondary" onClick={() => inputRef.current?.click()} className="shrink-0">
Scan a pedigree
</Button>
</Card>
)
}
const lines = uncertaintyLines(result)
return (
<Card className="overflow-hidden">
{fileInput}
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-body text-sm font-medium text-text-primary">Pedigree scanned — check it before saving</p>
<p className="font-body text-sm text-text-secondary">
Everything is filled in as editable entries. Compare against the photo and tap any ancestor to fix it.
</p>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => inputRef.current?.click()}>
Scan again
</Button>
</div>
{(result.matches?.sire || result.matches?.dam) && (
<p className="font-body text-sm text-text-secondary">
Linked to your herd:{' '}
{[result.matches.sire && `Sire ${result.matches.sire.name}`, result.matches.dam && `Dam ${result.matches.dam.name}`]
.filter(Boolean)
.join(' · ')}
. Their ancestors come from your records.
</p>
)}
{lines.length > 0 && (
<div className="rounded-md border border-accent-warm bg-surface px-3 py-2">
<p className="font-body text-sm font-medium text-text-primary">Double-check these (marked “Check”):</p>
<ul className="mt-1 list-disc pl-5 font-body text-sm text-text-secondary">
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
)}
{result.notes && <p className="font-body text-sm text-text-secondary">Note: {result.notes}</p>}
{error && (
<p role="alert" className="font-body text-sm text-danger">
{error}
</p>
)}
</div>
{imageUrl && (
<div className="border-t border-border">
<button
type="button"
onClick={() => setPhotoOpen((v) => !v)}
aria-expanded={photoOpen}
className="flex w-full items-center justify-between px-4 py-3 font-body text-sm font-medium text-text-primary"
>
<span>Scanned photo</span>
<span className="font-normal text-text-secondary">{photoOpen ? 'Hide' : 'Show'}</span>
</button>
{photoOpen && (
<div className="bg-surface-sunken p-2">
<a href={imageUrl} target="_blank" rel="noreferrer" title="Open full size">
<img
src={imageUrl}
alt="Scanned pedigree"
className="mx-auto max-h-[70vh] w-full rounded object-contain"
/>
</a>
<p className="mt-1 text-center font-body text-xs text-text-secondary">Tap the photo to open it full size.</p>
</div>
)}
</div>
)}
</Card>
)
}
export default PedigreeScan

View File

@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { searchRabbits, getRabbitAncestors } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useWeightUnit } from '../hooks/useWeightUnit'
import { formatWeight } from '../lib/format'
import {
ALL_SLOTS,
GEN1,
@ -14,9 +16,18 @@ import {
serializeManualAncestor,
parseManualAncestor,
formatManualAncestorDisplay,
formatManualAncestorDetails,
MANUAL_ANCESTOR_DEFAULTS,
} from '../lib/pedigree'
const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0', dob: '', dobUnknown: false }
const EMPTY_MANUAL_FORM = {
name: '',
farm: '',
ear_number: '',
weight_lbs: String(MANUAL_ANCESTOR_DEFAULTS.weight_lbs),
dob: '',
dobUnknown: true,
}
// Upper bound on the popup's height (its tallest state is the manual-entry
// form). Used to decide drop direction synchronously on open, before the popup
@ -57,14 +68,15 @@ function manualFormFromParsed(parsed) {
return {
...EMPTY_MANUAL_FORM,
...parsed,
weight_lbs: String(parsed?.weight_lbs ?? '0'),
weight_lbs: String(parsed?.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs),
dob: isKnownDate ? parsed.dob : '',
dobUnknown: !isKnownDate,
}
}
function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade }) {
function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fetchCascade, flagged = false }) {
const node = resolveEffectiveNode(path, state, cascadeCache)
const weightUnit = useWeightUnit()
const [open, setOpen] = useState(false)
const [mode, setMode] = useState('search') // 'search' | 'manual'
const [query, setQuery] = useState('')
@ -163,6 +175,8 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
const displayText =
node.mode === 'unknown' ? 'Unknown' : node.mode === 'manual' ? formatManualAncestorDisplay(node.name) : node.name
const detailText =
node.mode === 'manual' ? formatManualAncestorDetails(node.name, (w) => formatWeight(w, weightUnit)) : null
return (
<div ref={containerRef} data-testid={`pedigree-node-${slot}`} className="relative w-full min-w-[140px]">
@ -180,14 +194,22 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
setManualForm(EMPTY_MANUAL_FORM)
}
}}
className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
className={`flex min-h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent ${
flagged ? 'border-accent-warm ring-1 ring-accent-warm' : 'border-border'
}`}
>
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
<span className="flex w-full items-center gap-1.5 font-body text-xs text-text-secondary">
<span className="truncate">{label}</span>
{flagged && (
<span className="shrink-0 rounded bg-accent-warm px-1.5 font-medium text-surface">Check</span>
)}
</span>
<span
className={`w-full truncate font-body text-sm ${node.mode === 'unknown' ? 'text-text-secondary' : 'text-text-primary'}`}
>
{displayText}
</span>
{detailText && <span className="w-full truncate font-body text-xs text-text-secondary">{detailText}</span>}
</button>
{open && (
@ -278,7 +300,9 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
<label className="block px-0.5 font-body text-xs text-text-secondary">
Weight (lbs) — {MANUAL_ANCESTOR_DEFAULTS.weight_lbs} if not known
</label>
<input
type="number"
step="0.01"
@ -336,7 +360,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
)
}
function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }) {
function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade, flaggedSlots }) {
return (
<div className="flex min-w-0 flex-1 flex-col justify-around gap-3">
{entries.map(({ path, slot, label }) => (
@ -349,6 +373,7 @@ function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }
setSlotState={setSlotState}
cascadeCache={cascadeCache}
fetchCascade={fetchCascade}
flagged={flaggedSlots.has(slot)}
/>
))}
</div>
@ -366,7 +391,16 @@ function StackedBranch({ path, slotProps }) {
return (
<div className="flex flex-col gap-2">
<AncestorSlot path={entry.path} slot={entry.slot} label={entry.label} {...slotProps} />
<AncestorSlot
path={entry.path}
slot={entry.slot}
label={entry.label}
state={slotProps.state}
setSlotState={slotProps.setSlotState}
cascadeCache={slotProps.cascadeCache}
fetchCascade={slotProps.fetchCascade}
flagged={slotProps.flaggedSlots.has(entry.slot)}
/>
{childPaths.length > 0 && (
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
{childPaths.map((childPath) => (
@ -383,6 +417,9 @@ function PedigreeTree({
initialState,
initialCascade,
onChange,
// Slots a pedigree scan was unsure about. Each is highlighted until the user
// edits it (tracked locally in `reviewedSlots`).
uncertainSlots,
}) {
const [state, setState] = useState(() => initialState ?? createInitialPedigreeState())
const [cascadeCache, setCascadeCache] = useState(() => initialCascade ?? new Map())
@ -392,8 +429,12 @@ function PedigreeTree({
const cascadeFetchStarted = useRef(new Set())
const wide = useMediaQuery(WIDE_TREE_QUERY)
const [reviewedSlots, setReviewedSlots] = useState(() => new Set())
const flaggedSlots = new Set([...(uncertainSlots ?? [])].filter((slot) => !reviewedSlots.has(slot)))
function setSlotState(slot, value) {
setState((prev) => ({ ...prev, [slot]: value }))
setReviewedSlots((prev) => new Set(prev).add(slot))
}
function fetchCascade(id) {
@ -421,7 +462,7 @@ function PedigreeTree({
}, [])
if (!wide) {
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
const slotProps = { state, setSlotState, cascadeCache, fetchCascade, flaggedSlots }
return (
<div className="flex flex-col gap-4">
{GEN1.map(({ path, slot }) => (
@ -441,9 +482,9 @@ function PedigreeTree({
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
</div>
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} flaggedSlots={flaggedSlots} />
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} flaggedSlots={flaggedSlots} />
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} flaggedSlots={flaggedSlots} />
</div>
)
}

View File

@ -1,6 +1,7 @@
import Card from './Card'
import { LOCI, generateColorString } from '../lib/genetics'
import { SEX_LABELS, STATUS_LABELS, formatDate, formatWeight } from '../lib/format'
import { formatManualAncestorDetails } from '../lib/pedigree'
// Display order + formatting for the Details card. Empty values are skipped
// (except Name, which is required), so the review shows what was entered
@ -59,20 +60,53 @@ function Rows({ rows }) {
)
}
function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) {
function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit, existingMatch }) {
// Sire and Dam always show (so "Unknown" parents are visible at a glance);
// deeper ancestors only when something was actually entered.
const pedigreeRows = pedigreeSummary
.filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown'))
.map((node) => [node.label, node.display])
.map((node) => [
node.label,
node.manualText
? `${node.display} — ${formatManualAncestorDetails(node.manualText, (w) => formatWeight(w, weightUnit))}`
: node.display,
])
const setLoci = LOCI.filter((locus) => genetics[locus.key])
const colorString = generateColorString(genetics)
return (
<div className="space-y-4">
{existingMatch && (
<div role="status" className="rounded-lg border border-accent-warm bg-surface-raised px-4 py-3">
<p className="font-body text-sm font-medium text-text-primary">
{existingMatch.name}
{existingMatch.ear_number ? ` (${existingMatch.ear_number})` : ''} is already in your herd
{existingMatch.status === 'other' ? ' as a pedigree ancestor' : ''}.
</p>
<p className="mt-1 font-body text-sm text-text-secondary">
Saving will update that rabbit instead of adding a copy: it fills in details and ancestors it’s missing
{existingMatch.status === 'other' ? ' and moves it into your herd' : ''}. Nothing already recorded is
changed.{' '}
<a
href={`/herd/${existingMatch.id}`}
target="_blank"
rel="noreferrer"
className="font-medium text-accent hover:underline"
>
Open it in a new tab
</a>
</p>
</div>
)}
<Section title="Pedigree" onEdit={() => onEdit(1)}>
<Rows rows={pedigreeRows} />
{pedigreeSummary.some((node) => node.manualText) && (
<p className="mt-3 font-body text-sm text-text-secondary">
Ancestors that aren’t already in your herd will be saved as rabbits (status Other, under Archived) so
you can reuse them. Ones that match an existing rabbit by ear number or name and farm are linked instead.
</p>
)}
</Section>
<Section title="Details" onEdit={() => onEdit(2)}>

View File

@ -0,0 +1,25 @@
// Shrinks a photo before upload. Phone photos are 4000px+ and several MB; the
// scanning model reads up to ~2576px on the long edge, so 2400px keeps small
// handwriting legible while cutting upload time and per-scan cost. Re-encodes
// as JPEG, which also normalizes PNG/WebP/HEIC-from-Safari into one type.
// `imageOrientation: 'from-image'` applies the camera's EXIF rotation so the
// pedigree isn't sent sideways.
export const SCAN_MAX_LONG_EDGE = 2400
export async function downscaleImage(file, maxLongEdge = SCAN_MAX_LONG_EDGE, quality = 0.88) {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
const scale = Math.min(1, maxLongEdge / Math.max(bitmap.width, bitmap.height))
const canvas = document.createElement('canvas')
canvas.width = Math.round(bitmap.width * scale)
canvas.height = Math.round(bitmap.height * scale)
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
bitmap.close()
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Couldn’t process that photo.'))),
'image/jpeg',
quality
)
})
}

View File

@ -1,172 +0,0 @@
// Pedigree layout analysis (OCR Pedigree Scanning, Phase 3, Steps 3.2–3.4).
//
// A printed pedigree lays its ancestor cells out in vertical columns — one per
// generation — that fan left→right: subject, then parents, then grandparents,
// and (on a real 4–5 generation slip) great- and great-great-grandparents
// beyond. Within a column the cells stack vertically and double each generation
// (1 subject, 2 parents, 4 grandparents, …).
//
// These pure helpers take the flat word list from `runOCR` (each word carrying a
// `bbox` of {x0,y0,x1,y1}) and slice it into those columns/cells purely from the
// words' geometry — no OCR is re-run here. Column detection is *adaptive*: it
// finds the real whitespace gutters between generations rather than assuming a
// fixed number of equal-width bands, so it works regardless of how many
// generations the photo happens to contain. The pipeline (ocrPipeline.js) then
// anchors on the subject column and keeps only the first three generations.
// Two words whose top edges (y0) fall within this many pixels of each other are
// treated as being on the same printed line by `aggregateCellText`. Coordinates
// are in the preprocessed image space (<=1600px wide), so a few px of slop
// absorbs OCR baseline jitter without merging adjacent rows.
export const LINE_GROUP_TOLERANCE = 10
/**
* Detect the pedigree's generation columns from the whitespace gutters between
* them. Projects every word onto an x-axis coverage histogram, treats a run of
* empty bins at least `minGutterPx` wide as a column break, and buckets each
* word into the column whose x-range contains the word's horizontal center.
*
* Unlike a fixed 3-band split, this adapts to however many generations are in
* the image — the caller decides which columns are subject/parents/grandparents.
*
* @param {object[]} words words with a `bbox` ({x0,x1})
* @param {number} imageWidth full image width in px
* @param {object} [opts]
* @param {number} [opts.binSize] x-histogram bin width in px
* @param {number} [opts.minGutterPx] min empty-run width that splits two columns
* @returns {object[][]} non-empty column word arrays, ordered left→right
*/
export function detectColumns(words, imageWidth, opts = {}) {
if (words.length === 0) return []
const binSize = opts.binSize ?? Math.max(2, Math.round(imageWidth / 400))
const minGutterPx = opts.minGutterPx ?? imageWidth * 0.02
const minGutterBins = Math.max(1, Math.round(minGutterPx / binSize))
const binCount = Math.max(1, Math.ceil(imageWidth / binSize))
const coverage = new Array(binCount).fill(0)
for (const word of words) {
const start = Math.max(0, Math.floor(word.bbox.x0 / binSize))
const end = Math.min(binCount - 1, Math.floor(word.bbox.x1 / binSize))
for (let b = start; b <= end; b += 1) coverage[b] += 1
}
// Walk the histogram; a covered run is a column, an empty run >= minGutterBins
// closes the current column.
const ranges = []
let runStart = null
let emptyRun = 0
for (let b = 0; b < binCount; b += 1) {
if (coverage[b] > 0) {
if (runStart === null) runStart = b
emptyRun = 0
} else if (runStart !== null) {
emptyRun += 1
if (emptyRun >= minGutterBins) {
ranges.push({ x0: runStart * binSize, x1: (b - emptyRun + 1) * binSize })
runStart = null
emptyRun = 0
}
}
}
if (runStart !== null) ranges.push({ x0: runStart * binSize, x1: binCount * binSize })
const columns = ranges.map((r) => ({ ...r, words: [] }))
for (const word of words) {
const cx = (word.bbox.x0 + word.bbox.x1) / 2
let target = columns.find((c) => cx >= c.x0 && cx < c.x1)
if (!target) {
// Word center landed in a gutter — attach to the nearest column.
let bestDist = Infinity
for (const c of columns) {
const d = Math.abs(cx - (c.x0 + c.x1) / 2)
if (d < bestDist) {
bestDist = d
target = c
}
}
}
if (target) target.words.push(word)
}
return columns.map((c) => c.words).filter((w) => w.length > 0)
}
/**
* Split one column's words into `n` vertically-stacked cells by cutting at the
* `n - 1` largest vertical gaps between adjacent words (sorted by top edge).
* If the column has fewer words than cells, the trailing cells come back empty.
*
* @param {object[]} columnWords one column's words
* @param {number} n number of cells (e.g. 2 for parents, 4 for grandparents)
* @returns {object[][]} exactly `n` word arrays, top→bottom
*/
export function splitIntoCells(columnWords, n) {
const sorted = [...columnWords].sort((a, b) => a.bbox.y0 - b.bbox.y0)
if (n <= 1) return [sorted]
const gaps = []
for (let i = 1; i < sorted.length; i += 1) {
gaps.push({ index: i, size: sorted[i].bbox.y0 - sorted[i - 1].bbox.y0 })
}
const cuts = gaps
.sort((a, b) => b.size - a.size)
.slice(0, n - 1)
.map((g) => g.index)
.sort((a, b) => a - b)
const cells = []
let start = 0
for (const cut of cuts) {
cells.push(sorted.slice(start, cut))
start = cut
}
cells.push(sorted.slice(start))
while (cells.length < n) cells.push([])
return cells
}
/**
* Split a parents column into the sire (upper) and dam (lower) halves — the
* common n=2 case of `splitIntoCells`, named for readability at the call site.
*
* @param {object[]} columnWords parents-column words
* @returns {{ sire: object[], dam: object[] }}
*/
export function splitSireDam(columnWords) {
const [sire, dam] = splitIntoCells(columnWords, 2)
return { sire, dam }
}
/**
* Reassemble one cell's words into a multiline string. Words are grouped into
* lines by their top edge (within LINE_GROUP_TOLERANCE of the line's first
* word), each line is ordered left-to-right, and lines are joined top-to-bottom
* with newlines — reproducing roughly what is printed in that cell.
*
* @param {object[]} words one cell's words
* @returns {string}
*/
export function aggregateCellText(words) {
if (words.length === 0) return ''
const byTop = [...words].sort((a, b) => a.bbox.y0 - b.bbox.y0)
const lines = []
let anchorY = null
for (const word of byTop) {
if (anchorY === null || word.bbox.y0 - anchorY > LINE_GROUP_TOLERANCE) {
lines.push([])
anchorY = word.bbox.y0
}
lines[lines.length - 1].push(word)
}
return lines
.map((line) =>
line
.sort((a, b) => a.bbox.x0 - b.bbox.x0)
.map((word) => word.text)
.join(' '),
)
.join('\n')
}

View File

@ -1,92 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { detectColumns, splitIntoCells, splitSireDam, aggregateCellText } from './ocrLayout.js'
// Minimal word factory: only the fields the layout helpers read.
function word(text, x0, y0, x1 = x0 + 50, y1 = y0 + 20) {
return { text, bbox: { x0, y0, x1, y1 }, confidence: 90 }
}
test('detectColumns splits at whitespace gutters, ignoring how many columns there are', () => {
// Three generation columns with wide gutters between them; the parents and
// grandparents columns each have multiple vertically-stacked words at the
// same x — those must stay in one column, not split.
const words = [
word('Subject', 40, 500), // col A ~40–90
word('Sire', 400, 250), // col B ~400–450
word('Dam', 400, 750),
word('GrandA', 800, 120), // col C ~800–850
word('GrandB', 800, 380),
word('GrandC', 800, 640),
word('GrandD', 800, 900),
]
const cols = detectColumns(words, 1600)
assert.equal(cols.length, 3)
assert.deepEqual(cols[0].map((w) => w.text), ['Subject'])
assert.deepEqual(cols[1].map((w) => w.text).sort(), ['Dam', 'Sire'])
assert.deepEqual(cols[2].map((w) => w.text).sort(), ['GrandA', 'GrandB', 'GrandC', 'GrandD'])
})
test('detectColumns keeps words of one wide box together (no false split within a column)', () => {
// A single cell spanning x 40–300 across several lines: small inter-word gaps
// must not be mistaken for a column gutter.
const words = [
word("BASTET'S", 40, 100, 140),
word('SHANGHEI', 150, 100, 300),
word('Sable', 40, 130, 110),
word('Point', 120, 130, 200),
]
const cols = detectColumns(words, 1600)
assert.equal(cols.length, 1)
assert.equal(cols[0].length, 4)
})
test('splitIntoCells cuts a grandparents column into 4 by the largest vertical gaps', () => {
const col = [
word('SS', 800, 100),
word('SS2', 800, 122),
word('SD', 800, 400),
word('DS', 800, 700),
word('DD', 800, 1000),
]
const cells = splitIntoCells(col, 4)
assert.equal(cells.length, 4)
assert.deepEqual(cells[0].map((w) => w.text), ['SS', 'SS2'])
assert.deepEqual(cells[1].map((w) => w.text), ['SD'])
assert.deepEqual(cells[2].map((w) => w.text), ['DS'])
assert.deepEqual(cells[3].map((w) => w.text), ['DD'])
})
test('splitIntoCells pads with empty cells when there are too few words', () => {
const cells = splitIntoCells([word('only', 0, 0)], 4)
assert.equal(cells.length, 4)
assert.deepEqual(cells[0].map((w) => w.text), ['only'])
assert.deepEqual(cells[1], [])
assert.deepEqual(cells[3], [])
})
test('splitSireDam cuts at the largest vertical gap', () => {
const column = [
word('Sire', 350, 40),
word('line2', 350, 62),
word('Dam', 350, 400),
word('line2', 350, 422),
]
const { sire, dam } = splitSireDam(column)
assert.deepEqual(sire.map((w) => w.text), ['Sire', 'line2'])
assert.deepEqual(dam.map((w) => w.text), ['Dam', 'line2'])
})
test('aggregateCellText groups lines by y0 and orders words left-to-right', () => {
const words = [
word('Thumper', 120, 50),
word("Belowland's", 40, 52),
word('AB12', 60, 130),
word('Tattoo:', 40, 128),
]
assert.equal(aggregateCellText(words), "Belowland's Thumper\nTattoo: AB12")
})
test('aggregateCellText returns empty string for no words', () => {
assert.equal(aggregateCellText([]), '')
})

View File

@ -1,176 +0,0 @@
// Pedigree cell field extraction (OCR Pedigree Scanning, Phase 3, Step 3.5).
//
// Turns one cell's reassembled multiline text (from `aggregateCellText`) into
// the five structured fields the Add Rabbit wizard consumes. Everything here is
// pure string parsing with no OCR involved, so it is fully unit-testable
// (ocrParse.test.js). The OCR-side field names (`farmName`/`rabbitName`/
// `earNumber`/`weight`) are remapped to the serializer's shape later, in the
// wizard integration (plan Phase 5) — never here.
// "BASTET'S SHANGHEI" → farm "BASTET'S", name "SHANGHEI". Non-greedy first group
// so only the leading possessive token is split off as the farm. Case-insensitive
// so an ALL-CAPS possessive ("BASTET'S") matches its `'S` — real pedigrees are
// frequently printed entirely in caps.
const POSSESSIVE_RE = /^(.+?'s?)\s+(.+)$/i
// A line that is *only* a possessive farm token, with the rabbit's name printed
// on the following line ("BASTET'S" / "SHANGHEI").
const BARE_POSSESSIVE_RE = /^.+'s?$/i
// Ear-number / tattoo label, matched case-insensitively up to (and including)
// the separator; the value after it is matched case-sensitively as uppercase +
// digits so trailing lowercase letters from a following word aren't absorbed.
const EAR_LABEL_RE = /(?:tattoo|ear(?:\s*(?:#|no\.?|number))?)\s*[:#]?\s*/i
const EAR_VALUE_RE = /([A-Z0-9]{2,8})\b/
// A *bare* (unlabelled) ear token must contain at least one digit. Pedigrees are
// often all-caps, so an unlabelled all-letter token like "SHANGHEI"/"SHAMWOW" is
// a rabbit name, not an ear number — requiring a digit keeps names out of the
// ear field. (Labelled values, via EAR_VALUE_RE above, don't need a digit.)
const BARE_EAR_RE = /^(?=[A-Z0-9]*\d)([A-Z0-9]{2,8})$/
// MM/DD/YYYY, MM-DD-YYYY, MM/DD/YY, M/D/YY — separators / or -, 2- or 4-digit year.
const DATE_RE = /\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b/
// "8 lbs 4 oz" (also "8# 4oz") and "8.5 lbs" / "8.5 lb". A bare number with no
// unit is intentionally not treated as a weight.
const WEIGHT_LBS_OZ_RE = /(\d+(?:\.\d+)?)\s*(?:lbs?\.?|#)\s*(\d+(?:\.\d+)?)\s*oz\b/i
const WEIGHT_LBS_RE = /(\d+(?:\.\d+)?)\s*(?:lbs?\.?|#)\b/i
// Lines beginning with one of these are field labels, not the ancestor's name,
// so they're skipped when picking the name line.
const LABEL_PREFIXES = [
'tattoo', 'ear', 'dob', 'd.o.b', 'date', 'born', 'weight', 'wt',
'color', 'colour', 'variety', 'sex', 'breed', 'reg', 'grand',
]
function round2(n) {
return Math.round(n * 100) / 100
}
// A line is a "label line" if it leads with a known field label, or is itself a
// detectable ear number / date / weight value — anything that clearly isn't the
// printed name of the ancestor.
function isLabelLine(line) {
const lower = line.toLowerCase()
if (LABEL_PREFIXES.some((p) => lower.startsWith(p))) return true
if (BARE_EAR_RE.test(line)) return true
if (DATE_RE.test(line)) return true
if (WEIGHT_LBS_RE.test(line)) return true
return false
}
function parseName(lines) {
const idx = lines.findIndex((line) => !isLabelLine(line))
if (idx === -1) return { farmName: null, rabbitName: null }
const nameLine = lines[idx]
// Farm and name on one line: "BASTET'S SHANGHEI".
const match = nameLine.match(POSSESSIVE_RE)
if (match) {
return { farmName: match[1].trim(), rabbitName: match[2].trim() }
}
// Farm alone on its line, rabbit name on the next non-label line.
if (BARE_POSSESSIVE_RE.test(nameLine)) {
const next = lines.slice(idx + 1).find((line) => !isLabelLine(line))
if (next) return { farmName: nameLine.trim(), rabbitName: next.trim() }
}
return { farmName: null, rabbitName: nameLine }
}
function parseEarNumber(lines) {
// Labelled value takes precedence ("Tattoo: AB12").
for (const line of lines) {
const label = line.match(EAR_LABEL_RE)
if (!label) continue
const after = line.slice(label.index + label[0].length)
const value = after.match(EAR_VALUE_RE)
if (value) return value[1].toUpperCase()
}
// Otherwise a bare alphanumeric token alone on its own line.
for (const line of lines) {
const bare = line.match(BARE_EAR_RE)
if (bare) return bare[1].toUpperCase()
}
return null
}
function parseDob(lines) {
for (const line of lines) {
const match = line.match(DATE_RE)
if (!match) continue
const month = parseInt(match[1], 10)
const day = parseInt(match[2], 10)
let year = parseInt(match[3], 10)
// Two-digit years pivot on the current year: a DOB is never in the future
// (+1 slop for a slip printed around New Year), and older slips genuinely
// carry 1990s ancestors — so "24" → 2024 but "98" → 1998.
if (match[3].length === 2) {
const pivot = (new Date().getFullYear() + 1) % 100
year += year <= pivot ? 2000 : 1900
}
// new Date(y, m, 0) is the last day of month m, so this rejects impossible
// dates like 2/31 instead of emitting an invalid "YYYY-02-31" string.
const daysInMonth = new Date(year, month, 0).getDate()
if (month < 1 || month > 12 || day < 1 || day > daysInMonth) continue
const mm = String(month).padStart(2, '0')
const dd = String(day).padStart(2, '0')
return `${year}-${mm}-${dd}`
}
return null
}
function parseWeight(lines) {
// Prefer the lbs+oz form (it also partially matches the lbs-only regex).
for (const line of lines) {
const lo = line.match(WEIGHT_LBS_OZ_RE)
if (lo) return round2(parseFloat(lo[1]) + parseFloat(lo[2]) / 16)
}
for (const line of lines) {
const l = line.match(WEIGHT_LBS_RE)
if (l) return parseFloat(l[1])
}
return null
}
/**
* Extract the five pedigree fields from one cell's multiline OCR text.
*
* @param {string} cellText output of `aggregateCellText`
* @returns {{ farmName: string|null, rabbitName: string|null,
* earNumber: string|null, dob: string|null, weight: number|null }}
* `dob` is `YYYY-MM-DD` or null; `weight` is decimal lbs or null. Missing
* fields are null — the "Unknown"/0 sentinels are only applied later, at save.
*/
export function parseCellFields(cellText) {
const lines = (cellText || '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
return {
...parseName(lines),
earNumber: parseEarNumber(lines),
dob: parseDob(lines),
weight: parseWeight(lines),
}
}
// Structural labels every printed ancestor cell carries (even when the value is
// blank): Ear #, Weight, DOB, Sex, Color, Legs, Reg #, GC #, Variety, Tattoo.
const CELL_LABEL_RE =
/\b(?:ear|tattoo|weight|wt|d\.?o\.?b|dob|sex|colou?r|legs?|reg|gc|variety)\b/i
/**
* Heuristic: does this cell text read like a real ancestor cell rather than the
* form's title/header or stray margin text? A genuine cell always has at least
* one printed field label; the title ("Bastet's Bunnies Holland Lop Pedigree")
* has none. The pipeline uses this to drop header text that lands in a cell slot.
*
* @param {string} cellText
* @returns {boolean}
*/
export function looksLikeAncestorCell(cellText) {
return CELL_LABEL_RE.test(cellText || '')
}

View File

@ -1,133 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseCellFields, looksLikeAncestorCell } from './ocrParse.js'
// --- The three cases required by plan Step 3.5 ---
test('case 1: all five fields present and labelled', () => {
const cell = [
"Belowland's Thumper",
'Tattoo: AB12',
'DOB: 03/15/2024',
'Weight: 8 lbs 4 oz',
].join('\n')
assert.deepEqual(parseCellFields(cell), {
farmName: "Belowland's",
rabbitName: 'Thumper',
earNumber: 'AB12',
dob: '2024-03-15',
weight: 8.25,
})
})
test('case 2: only name and ear number (DOB and weight absent)', () => {
const cell = ['Clover', 'RZ7'].join('\n')
assert.deepEqual(parseCellFields(cell), {
farmName: null,
rabbitName: 'Clover',
earNumber: 'RZ7',
dob: null,
weight: null,
})
})
test('case 3: name has no possessive (rabbit name only, no farm)', () => {
const cell = ['Midnight', 'Ear #: XY99'].join('\n')
const result = parseCellFields(cell)
assert.equal(result.farmName, null)
assert.equal(result.rabbitName, 'Midnight')
assert.equal(result.earNumber, 'XY99')
})
// --- Per-field rule coverage ---
test('possessive split handles a plain "s without apostrophe-s', () => {
// "Lund Lops' Seabreeze" → farm "Lund Lops'", name "Seabreeze"
const result = parseCellFields("Lund Lops' Seabreeze")
assert.equal(result.farmName, "Lund Lops'")
assert.equal(result.rabbitName, 'Seabreeze')
})
test('weight: decimal lbs form', () => {
assert.equal(parseCellFields('Bunny\nWeight 8.5 lbs').weight, 8.5)
})
test('weight: bare number with no unit is not a weight', () => {
assert.equal(parseCellFields('Bunny\n8.5').weight, null)
})
test('dob: 2-digit year maps to 20xx and slashes/dashes both parse', () => {
assert.equal(parseCellFields('Bunny\nborn 5/9/24').dob, '2024-05-09')
assert.equal(parseCellFields('Bunny\n12-25-2023').dob, '2023-12-25')
})
test('dob: 2-digit year beyond the current-year pivot maps to 19xx', () => {
// Older slips carry 1990s ancestors; "98" must not become a future 2098 DOB.
assert.equal(parseCellFields('Bunny\nDOB: 3/15/98').dob, '1998-03-15')
})
test('dob: invalid month is rejected', () => {
assert.equal(parseCellFields('Bunny\n13/40/2024').dob, null)
})
test('dob: impossible day for the month is rejected', () => {
assert.equal(parseCellFields('Bunny\n2/31/2024').dob, null)
// 2024 is a leap year — 2/29 is real.
assert.equal(parseCellFields('Bunny\n2/29/2024').dob, '2024-02-29')
assert.equal(parseCellFields('Bunny\n2/29/2023').dob, null)
})
test('ear number: labelled value wins over a later bare token', () => {
const result = parseCellFields('Rocky\nTattoo: GH42\nREW')
assert.equal(result.earNumber, 'GH42')
})
// --- ALL-CAPS pedigrees (real-world: BASTET'S SHANGHEI etc.) ---
test('all-caps possessive splits farm from name on one line', () => {
const result = parseCellFields('BASTET’S SHANGHEI'.replace('’', "'"))
assert.equal(result.farmName, "BASTET'S")
assert.equal(result.rabbitName, 'SHANGHEI')
})
test('farm on its own line, rabbit name on the next line', () => {
const cell = ["BASTET'S", 'SHAMWOW', 'Ear #: BSHAMW', 'Weight: 3 # 15 oz'].join('\n')
const result = parseCellFields(cell)
assert.equal(result.farmName, "BASTET'S")
assert.equal(result.rabbitName, 'SHAMWOW')
assert.equal(result.earNumber, 'BSHAMW')
})
test('an all-caps name is not mistaken for a bare ear number', () => {
// "SHANGHEI" is all letters → a name, not an ear. The labelled ear wins.
const cell = ['MIDNIGHT', 'Ear #: XY99'].join('\n')
const result = parseCellFields(cell)
assert.equal(result.rabbitName, 'MIDNIGHT')
assert.equal(result.earNumber, 'XY99')
})
test('a bare all-letter token is not treated as an ear number', () => {
// No label, no digit → not an ear; it becomes the name instead.
const result = parseCellFields('SHAMWOW')
assert.equal(result.earNumber, null)
assert.equal(result.rabbitName, 'SHAMWOW')
})
test('looksLikeAncestorCell distinguishes a real cell from the form title', () => {
assert.equal(looksLikeAncestorCell("BASTET'S SHAMWOW\nEar #: BSHAMW\nWeight: 3 lbs"), true)
assert.equal(looksLikeAncestorCell("Bastet's Bunnies\nHolland Lop"), false)
assert.equal(looksLikeAncestorCell(''), false)
})
test('empty / whitespace cell yields all nulls', () => {
assert.deepEqual(parseCellFields(' \n \n'), {
farmName: null,
rabbitName: null,
earNumber: null,
dob: null,
weight: null,
})
})

View File

@ -1,113 +0,0 @@
// Full pedigree OCR pipeline (OCR Pedigree Scanning, Phase 3, Step 3.6).
//
// Orchestrates the Phase 3 pieces end to end: OCR a preprocessed pedigree image,
// figure out which columns are the subject / parents / grandparents (real
// pedigrees carry 4–5 generations plus margin text, so this is done adaptively
// and anchored on the subject rather than by fixed bands), and parse each cell
// into structured fields. The returned object is what the Add Rabbit wizard
// turns into the pedigree tree's initial state (plan Phase 5).
import { runOCR } from './ocrRun.js'
import { detectColumns, splitIntoCells, aggregateCellText } from './ocrLayout.js'
import { parseCellFields, looksLikeAncestorCell } from './ocrParse.js'
const EMPTY_CELL = { farmName: null, rabbitName: null, earNumber: null, dob: null, weight: null }
// Words from the certification/signature block and the printed form title sit
// outside the ancestor tree and would otherwise pollute the subject/sire cells.
// Matched case-insensitively as whole words. Kept deliberately small and
// pedigree-boilerplate-specific so it can't eat a real rabbit/farm name (note:
// "pedigree" is safe — no rabbit is named that — but breed words like "lop" are
// intentionally NOT here, since farms are often named "<Something> Lops").
const BOILERPLATE_RE =
/\b(?:certify|certifies|certified|hereby|signed|knowledge|belief|pedigree|printed|software|registrar|phone)\b/i
// Positive signal that a column's text is an actual rabbit cell (vs. the cert
// block or stray margin text): a labelled ear/tattoo/weight/DOB, or a possessive
// farm token. Used to find the subject column and skip the left margin.
const RABBIT_CELL_RE = /(?:ear\s*#|tattoo|weight|d\.?o\.?b|\bsex\b|'s?\b)/i
function isBoilerplateWord(text) {
return BOILERPLATE_RE.test(text)
}
// Parse a cell, but reject text that doesn't read like a real ancestor cell
// (the form title/header has no field labels) so it can't masquerade as a rabbit.
function parseCell(words) {
const text = aggregateCellText(words)
if (!looksLikeAncestorCell(text)) return { ...EMPTY_CELL }
return parseCellFields(text)
}
// The subject is the leftmost column that reads like a rabbit cell — anything to
// its left (a certification/signature margin) is skipped. Falls back to column 0
// if nothing matches, so a poorly-OCR'd subject still yields a best effort.
function findSubjectColumnIndex(columns) {
for (let i = 0; i < columns.length; i += 1) {
if (RABBIT_CELL_RE.test(aggregateCellText(columns[i]))) return i
}
return 0
}
/**
* Synchronous core of the pipeline: from a flat word list + image width, produce
* the seven-cell pedigree. Split out from `extractPedigree` so it can be unit-
* tested and reused by the dev harness without re-running OCR.
*
* Real pedigrees stack the four grandparents vertically in a single column
* (top→bottom: sire_sire, sire_dam, dam_sire, dam_dam), so they're split by the
* three largest vertical gaps — not into a 2×2 quadrant grid.
*
* @param {object[]} words flat words from `runOCR`
* @param {number} imageWidth full image width in px
* @returns {object} seven keys, each a `parseCellFields` result
*/
export function extractFromWords(words, imageWidth) {
const cleaned = words.filter((w) => !isBoilerplateWord(w.text))
const columns = detectColumns(cleaned, imageWidth)
const subjectIdx = findSubjectColumnIndex(columns)
const subjectWords = columns[subjectIdx] ?? []
const parentWords = columns[subjectIdx + 1] ?? []
const grandparentWords = columns[subjectIdx + 2] ?? []
const [sireWords, damWords] = splitIntoCells(parentWords, 2)
const [ssWords, sdWords, dsWords, ddWords] = splitIntoCells(grandparentWords, 4)
return {
subject: parseCell(subjectWords),
sire: parseCell(sireWords),
dam: parseCell(damWords),
sire_sire: parseCell(ssWords),
sire_dam: parseCell(sdWords),
dam_sire: parseCell(dsWords),
dam_dam: parseCell(ddWords),
}
}
// Decode a blob just to read its pixel width — `detectColumns` needs it and the
// Tesseract `Page` doesn't expose it. createImageBitmap is available in every
// browser this app targets.
async function imageWidthOf(blob) {
const bitmap = await createImageBitmap(blob)
const { width } = bitmap
bitmap.close?.()
return width
}
/**
* Extract a full pedigree from a preprocessed image blob.
*
* @param {Blob} imageBlob preprocessed (binarized, <=1600px) pedigree image
* @returns {Promise<object>} seven keys — `subject`, `sire`, `dam`,
* `sire_sire`, `sire_dam`, `dam_sire`, `dam_dam` — each the
* `{ farmName, rabbitName, earNumber, dob, weight }` shape from
* `parseCellFields`. Sparse cells yield null fields rather than throwing.
*/
export async function extractPedigree(imageBlob) {
const [{ words }, imageWidth] = await Promise.all([
runOCR(imageBlob),
imageWidthOf(imageBlob),
])
return extractFromWords(words, imageWidth)
}

View File

@ -1,83 +0,0 @@
// Client-side image preprocessing for OCR (OCR Pedigree Scanning, Phase 2).
//
// Tesseract reads a printed pedigree far more reliably from a downscaled,
// high-contrast black-and-white image than from a raw phone photo. This module
// does that normalization entirely with the browser canvas API — no image
// library (jimp was deliberately not added) — and returns a PNG Blob ready to
// hand to the OCR pipeline (Phase 3).
// Tesseract gains nothing from images wider than this and slows down on them,
// so anything wider is scaled down proportionally. Narrower photos are left at
// their native size (never upscaled).
export const MAX_WIDTH = 1600
// Luminance cutoff for the black/white binarization pass: pixels at or above it
// become white (255), below become black (0). Tuned down from the 128 starting
// point to 103 during OCR Phase 2 Step 2.2 validation — at 128, a real photographed
// pedigree's uneven lighting / shadowed paper edges binarized into large black
// blobs that swallowed the bottom-row cells; 103 keeps those cells legible.
// Lower it further if blobs reappear, raise it if faint text drops out.
export const BINARIZE_THRESHOLD = 103
// Decode a File/Blob into a loaded HTMLImageElement, cleaning up the object URL.
function decodeImage(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Could not decode the selected image.'))
}
img.src = url
})
}
// Promisified canvas.toBlob (PNG).
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error('Failed to encode the processed image.'))
}, 'image/png')
})
}
/**
* Preprocess an image for OCR: downscale to <= MAX_WIDTH, convert to grayscale,
* then binarize to pure black/white. Accepts a File or Blob; resolves to a PNG
* Blob. The original is not retained — only this processed blob is used by the
* scanner and the zone picker (their bounding boxes live in this coordinate space).
*/
export async function preprocessImage(file) {
const img = await decodeImage(file)
// Scale down proportionally only when wider than MAX_WIDTH; never upscale.
const scale = img.width > MAX_WIDTH ? MAX_WIDTH / img.width : 1
const width = Math.round(img.width * scale)
const height = Math.round(img.height * scale)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, width, height)
// One pass over the pixels: grayscale (standard luminance) then threshold.
const imageData = ctx.getImageData(0, 0, width, height)
const px = imageData.data
for (let i = 0; i < px.length; i += 4) {
const luma = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2]
const bw = luma >= BINARIZE_THRESHOLD ? 255 : 0
px[i] = bw
px[i + 1] = bw
px[i + 2] = bw
// alpha (px[i + 3]) is left untouched
}
ctx.putImageData(imageData, 0, 0)
return canvasToBlob(canvas)
}

View File

@ -1,70 +0,0 @@
// Tesseract OCR runner (OCR Pedigree Scanning, Phase 3, Step 3.1).
//
// Wraps tesseract.js with the locally-vendored runtime assets (OCR Phase 1) so
// nothing is fetched from a CDN at runtime — the app is self-hosted and must
// work offline. Accepts a preprocessed image blob (OCR Phase 2) and returns a
// flat list of recognized words, each with its bounding box and confidence, for
// the layout/parse stages (Steps 3.2–3.6) to slice into pedigree cells.
import { createWorker } from 'tesseract.js'
// Vendored under client/public/tesseract/ — served from the site root in both
// Vite dev and the nginx/dist production build. corePath is a *directory* so
// tesseract.js v7 auto-selects the right LSTM core variant for the browser
// (plain / simd / relaxedsimd); langPath + gzip:false are required because the
// vendored eng.traineddata is uncompressed (default gzip:true 404s on .gz).
const TESSERACT_BASE = '/tesseract/'
// Words below this Tesseract confidence are dropped as OCR noise before any
// layout analysis runs, so a stray speck never lands in a pedigree cell.
export const MIN_WORD_CONFIDENCE = 60
// A single worker is reused across scans (loading the WASM core + traineddata
// is expensive, ~9MB). Created lazily on the first runOCR call.
let workerPromise = null
function getWorker() {
if (!workerPromise) {
workerPromise = createWorker('eng', undefined, {
workerPath: `${TESSERACT_BASE}worker.min.js`,
corePath: TESSERACT_BASE,
langPath: TESSERACT_BASE,
gzip: false,
})
}
return workerPromise
}
/**
* Run Tesseract on a preprocessed image blob.
*
* tesseract.js v7 nests recognized text as blocks → paragraphs → lines → words
* and only populates that structure when `blocks: true` is requested (the
* default output is text-only). We flatten it to plain `words`/`lines` arrays —
* the shape the layout stage expects — and drop low-confidence words.
*
* @param {Blob} imageBlob preprocessed (binarized, <=1600px) image
* @returns {Promise<{ words: object[], lines: object[] }>} flattened result;
* each word has `text`, `bbox` ({x0,y0,x1,y1}), and `confidence`.
*/
export async function runOCR(imageBlob) {
const worker = await getWorker()
const { data } = await worker.recognize(imageBlob, {}, { blocks: true })
const words = []
const lines = []
for (const block of data.blocks ?? []) {
for (const paragraph of block.paragraphs ?? []) {
for (const line of paragraph.lines ?? []) {
lines.push(line)
for (const word of line.words ?? []) {
if (word.confidence >= MIN_WORD_CONFIDENCE) {
words.push(word)
}
}
}
}
}
return { words, lines }
}

View File

@ -48,20 +48,26 @@ const TEXT_COLUMN_BY_SLOT = {
// Manually-entered ancestors capture more than a bare name (Farm, Ear Number,
// Weight, DOB), but each `_name_text` column is still a single text field, so
// the extra fields are persisted as a JSON string in that same column. Only
// Name/Farm/Ear Number are meaningful for the user to fill in; Weight and DOB
// default to placeholders ancestor records never had real values for.
const MANUAL_ANCESTOR_DEFAULTS = { weight_lbs: 0, dob: 'Unknown' }
// the extra fields are persisted as a JSON string in that same column.
// Name/Farm/Ear Number are required on entry; Weight and DOB are always stored
// too, falling back to these defaults (user rule: no weight → 3 lbs, no DOB →
// "Unknown"). Entries saved before the 3 lb default stored weight 0 to mean
// "not entered", so a non-positive weight is read back as the default as well.
export const MANUAL_ANCESTOR_DEFAULTS = { weight_lbs: 3, dob: 'Unknown' }
function normalizeAncestorWeight(weight) {
const value = Number(weight)
return weight === '' || weight === null || weight === undefined || !Number.isFinite(value) || value <= 0
? MANUAL_ANCESTOR_DEFAULTS.weight_lbs
: value
}
export function serializeManualAncestor({ name, farm, ear_number, weight_lbs, dob }) {
return JSON.stringify({
name: name?.trim() || '',
farm: farm?.trim() || '',
ear_number: ear_number?.trim() || '',
weight_lbs:
weight_lbs === '' || weight_lbs === null || weight_lbs === undefined
? MANUAL_ANCESTOR_DEFAULTS.weight_lbs
: Number(weight_lbs),
weight_lbs: normalizeAncestorWeight(weight_lbs),
dob: dob?.trim() || MANUAL_ANCESTOR_DEFAULTS.dob,
})
}
@ -78,8 +84,8 @@ export function parseManualAncestor(text) {
name: parsed.name,
farm: parsed.farm ?? '',
ear_number: parsed.ear_number ?? '',
weight_lbs: parsed.weight_lbs ?? MANUAL_ANCESTOR_DEFAULTS.weight_lbs,
dob: parsed.dob ?? MANUAL_ANCESTOR_DEFAULTS.dob,
weight_lbs: normalizeAncestorWeight(parsed.weight_lbs),
dob: parsed.dob || MANUAL_ANCESTOR_DEFAULTS.dob,
}
}
} catch {
@ -94,6 +100,16 @@ export function parseManualAncestor(text) {
}
}
// Second line for a manual ancestor: "3.00 lbs · DOB Unknown". `formatWeight`
// is passed in (lib/format.js) so callers apply the user's weight unit.
export function formatManualAncestorDetails(text, formatWeight) {
const parsed = parseManualAncestor(text)
if (!parsed) return null
const date = /^\d{4}-\d{2}-\d{2}$/.test(parsed.dob) ? new Date(`${parsed.dob}T00:00:00`) : null
const dob = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString('en-US') : parsed.dob
return `${formatWeight(parsed.weight_lbs)} · DOB ${dob}`
}
export function formatManualAncestorDisplay(text) {
const parsed = parseManualAncestor(text)
if (!parsed) return null
@ -204,7 +220,9 @@ export function buildPedigreeSummary(state, cascadeCache) {
: eff.mode === 'manual'
? formatManualAncestorDisplay(eff.name)
: eff.name
return { slot, label, display }
// `manualText` lets the Review step add the weight/DOB line in the user's
// weight unit (this lib has no access to settings).
return { slot, label, display, manualText: eff.mode === 'manual' ? eff.name : null }
})
}
@ -241,3 +259,43 @@ export function buildPedigreePayload(state, cascadeCache) {
return payload
}
// --- Pedigree scan → tree state -------------------------------------------
// A scan result (POST /api/pedigree-scan) carries `ancestors` keyed by slot
// and `matches` for confident herd matches on sire/dam. Matched parents become
// linked nodes (their branch then locks to the herd record's own pedigree, per
// FEATURE_OCR_Pedigree_Scan.md Q6); every other named ancestor becomes a
// manual node the user can review and edit like hand entry.
export function pedigreeStateFromScan({ ancestors = {}, matches = {} } = {}) {
const state = createInitialPedigreeState()
for (const { slot } of ALL_SLOTS) {
const match = matches[slot]
const scanned = ancestors[slot]
if (match) {
state[slot] = { mode: 'linked', id: match.id, name: match.name }
} else if (scanned?.name) {
state[slot] = {
mode: 'manual',
id: null,
name: serializeManualAncestor({
name: scanned.name,
farm: scanned.farm ?? '',
ear_number: scanned.ear_number ?? '',
weight_lbs: scanned.weight_lbs ?? '',
dob: scanned.dob ?? '',
}),
}
}
}
return state
}
// Slots the scan wasn't sure about, for highlighting in the tree. A matched
// parent is excluded: it's linked to a herd record, not the scanned text.
export function uncertainScanSlots({ ancestors = {}, matches = {} } = {}) {
return new Set(
ALL_SLOTS.filter(({ slot }) => !matches[slot] && ancestors[slot]?.uncertain?.length > 0).map(
({ slot }) => slot
)
)
}

View File

@ -6,6 +6,9 @@ import {
serializeManualAncestor,
parseManualAncestor,
formatManualAncestorDisplay,
formatManualAncestorDetails,
pedigreeStateFromScan,
uncertainScanSlots,
} from './pedigree.js'
test('manual ancestor serialize/parse round-trips', () => {
@ -69,3 +72,58 @@ test('buildPedigreePayload: a manual parent leaves deeper slots editable and per
assert.equal(payload.sire_name_text, 'Bob')
assert.equal(payload.paternal_grandsire_name_text, 'Old Bob')
})
test('pedigreeStateFromScan links matched parents and makes named ancestors manual', () => {
const state = pedigreeStateFromScan({
ancestors: {
sire: { name: 'Hoppin-pop', farm: "Peachy Buns'", ear_number: 'HP1', dob: null, weight_lbs: null, uncertain: [] },
dam: { name: 'Black Betty', farm: "Cody's", ear_number: 'BB2', dob: '2024-03-04', weight_lbs: 4.25, uncertain: ['dob'] },
paternal_grandsire: { name: null, farm: null, ear_number: null, dob: null, weight_lbs: null, uncertain: [] },
},
matches: { sire: { id: 7, name: 'Hoppin-pop' }, dam: null },
})
assert.deepEqual(state.sire, { mode: 'linked', id: 7, name: 'Hoppin-pop' })
assert.equal(state.dam.mode, 'manual')
const dam = parseManualAncestor(state.dam.name)
assert.equal(dam.name, 'Black Betty')
assert.equal(dam.farm, "Cody's")
assert.equal(dam.dob, '2024-03-04')
assert.equal(dam.weight_lbs, 4.25)
// Unnamed or missing ancestors stay at the empty default.
assert.equal(state.paternal_grandsire.mode, 'unknown')
assert.equal(state.mm_granddam.mode, 'unknown')
})
test('uncertainScanSlots skips matched parents and certain ancestors', () => {
const slots = uncertainScanSlots({
ancestors: {
sire: { name: 'A', uncertain: ['ear_number'] },
dam: { name: 'B', uncertain: ['dob'] },
maternal_grandsire: { name: 'C', uncertain: [] },
},
matches: { sire: { id: 1, name: 'A' } },
})
assert.deepEqual([...slots], ['dam'])
})
test('manual ancestors default to 3 lbs and Unknown DOB, including legacy 0-weight entries', () => {
const fresh = parseManualAncestor(serializeManualAncestor({ name: 'No Info', farm: 'X', ear_number: 'A1' }))
assert.equal(fresh.weight_lbs, 3)
assert.equal(fresh.dob, 'Unknown')
const legacy = parseManualAncestor(JSON.stringify({ name: 'Old', farm: '', ear_number: '', weight_lbs: 0, dob: '' }))
assert.equal(legacy.weight_lbs, 3)
assert.equal(legacy.dob, 'Unknown')
assert.equal(parseManualAncestor('Plain Name').weight_lbs, 3)
const real = parseManualAncestor(serializeManualAncestor({ name: 'Real', weight_lbs: '4.5', dob: '2024-03-04' }))
assert.equal(real.weight_lbs, 4.5)
assert.equal(real.dob, '2024-03-04')
})
test('formatManualAncestorDetails shows weight and DOB', () => {
const fmt = (w) => `${w} lbs`
assert.equal(formatManualAncestorDetails(serializeManualAncestor({ name: 'A' }), fmt), '3 lbs · DOB Unknown')
assert.equal(
formatManualAncestorDetails(serializeManualAncestor({ name: 'B', weight_lbs: 5, dob: '2024-03-04' }), fmt),
'5 lbs · DOB 3/4/2024'
)
})

View File

@ -7,9 +7,17 @@ import RabbitDetailsForm from '../components/RabbitDetailsForm'
import GeneticsForm from '../components/GeneticsForm'
import ReviewStep from '../components/ReviewStep'
import StickyActionBar from '../components/StickyActionBar'
import PedigreeScan from '../components/PedigreeScan'
import Button from '../components/Button'
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
import { createInitialPedigreeState } from '../lib/pedigree'
import {
getSettings,
getRabbit,
createRabbit,
uploadRabbitPhoto,
getPedigreeScanStatus,
findExistingRabbit,
} from '../api'
import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree'
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
const NAME_INPUT_ID = 'add-rabbit-name'
@ -37,6 +45,45 @@ const INITIAL_GENETICS = {
locus_v: null,
}
// The scanned subject's fields that should overwrite the form. Only values the
// scan actually found replace what's there (e.g. the settings-default farm
// survives a pedigree that doesn't print one).
function detailsFromScan(subject) {
const out = {}
for (const key of ['name', 'farm', 'ear_number', 'dob', 'breed', 'color_name']) {
if (subject[key]) out[key] = subject[key]
}
if (subject.weight_lbs != null) out.weight_lbs = subject.weight_lbs
if (subject.sex && subject.sex !== 'unknown') out.sex = subject.sex
return out
}
const FIELD_LABELS = {
color_name: 'color',
weight_lbs: 'weight',
ear_number: 'ear number',
dob: 'date of birth',
sire_id: 'sire',
dam_id: 'dam',
acquired_date: 'acquired date',
death_date: 'death date',
}
// Summary shown on the rabbit's page after "adding" a rabbit that was already
// in the herd (the server topped up that record instead of duplicating it).
function mergeNotice(rabbit) {
const { filled = [], ancestors_added: added = 0, ancestors_updated: updated = 0, conflicts = [] } =
rabbit.merged
const parts = []
if (filled.length) parts.push(`filled in ${filled.map((f) => FIELD_LABELS[f] ?? f.replace(/_/g, ' ')).join(', ')}`)
if (added) parts.push(`added ${added} ancestor${added === 1 ? '' : 's'}`)
if (updated) parts.push(`completed ${updated} existing ancestor${updated === 1 ? '' : 's'}`)
let text = `${rabbit.name} was already in your herd, so it was updated instead of added again`
text += parts.length ? `: ${parts.join('; ')}.` : ' (nothing new to add).'
if (conflicts.length) text += ` Kept the existing record where the pedigree differed — ${conflicts.join('; ')}.`
return text
}
function Stepper({ step, canReach, onSelect }) {
return (
<ol className="flex items-center gap-2">
@ -99,6 +146,13 @@ function AddRabbit() {
// The name error only shows once the user has tried to move past step 2,
// not the moment they arrive on an empty form.
const [triedNext, setTriedNext] = useState(false)
// Pedigree scan: the result drives highlighting/notes, and `pedigreeSeed` +
// a bumped `pedigreeKey` remount PedigreeTree with the scanned ancestors
// (it only reads `initialState` at mount).
const [scanResult, setScanResult] = useState(null)
const [scanImageUrl, setScanImageUrl] = useState(null)
const [pedigreeSeed, setPedigreeSeed] = useState(null)
const [pedigreeKey, setPedigreeKey] = useState(0)
const step2Valid = details.name.trim().length > 0
@ -110,6 +164,7 @@ function AddRabbit() {
const step = requestedStep === 3 && !step2Valid ? 2 : requestedStep
const isDirty =
Boolean(scanResult) ||
Boolean(details.name.trim()) ||
Boolean(photoFile) ||
Object.values(genetics).some(Boolean) ||
@ -126,7 +181,33 @@ function AddRabbit() {
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [isDirty, saving])
// The photo is only held as an in-memory object URL; free it when replaced
// or when leaving the page.
useEffect(() => {
if (!scanImageUrl) return
return () => URL.revokeObjectURL(scanImageUrl)
}, [scanImageUrl])
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
// On Review, check whether saving would top up an existing rabbit so the
// user knows before pressing Save.
const { data: existingMatch } = useQuery({
queryKey: ['find-existing', details.name, details.farm, details.ear_number, details.sex, details.dob],
queryFn: () =>
findExistingRabbit({
name: details.name,
farm: details.farm,
ear_number: details.ear_number,
sex: details.sex,
dob: details.dob,
}),
enabled: requestedStep === 3 && step2Valid,
})
const { data: scanStatus } = useQuery({
queryKey: ['pedigree-scan-status'],
queryFn: getPedigreeScanStatus,
staleTime: Infinity,
})
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
queryKey: ['rabbit', sireId],
queryFn: () => getRabbit(sireId),
@ -156,8 +237,9 @@ function AddRabbit() {
return <PageHeader title="Add Rabbit" subtitle="Loading…" />
}
const pedigreeInitialState =
sireId || damId
const pedigreeInitialState = pedigreeSeed
? pedigreeSeed
: sireId || damId
? {
...createInitialPedigreeState(),
...(sireRabbit ? { sire: { mode: 'linked', id: sireRabbit.id, name: sireRabbit.name } } : {}),
@ -173,6 +255,20 @@ function AddRabbit() {
setGenetics((prev) => ({ ...prev, ...partial }))
}
function handleScanned(result, imageUrl) {
setPedigreeSeed(pedigreeStateFromScan(result))
setPedigreeKey((k) => k + 1)
setDetails((prev) => ({ ...prev, ...detailsFromScan(result.subject ?? {}) }))
setScanResult(result)
setScanImageUrl(imageUrl)
}
// Scanning replaces the tree wholesale, so confirm before discarding entries.
function confirmReplaceWithScan() {
const hasEntries = pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
return !hasEntries || window.confirm('Replace the ancestors you’ve entered with the scanned pedigree?')
}
function goToStep(n) {
const next = new URLSearchParams(searchParams)
if (n === 1) next.delete('step')
@ -201,21 +297,25 @@ function AddRabbit() {
setSaving(false)
return
}
const notice = created.merged ? mergeNotice(created) : null
// The rabbit exists from here on. A failed photo upload must not leave the
// user on Review, where pressing Save again would create a duplicate —
// go to the new rabbit and say what happened instead.
if (photoFile) {
// go to the new rabbit and say what happened instead. A topped-up rabbit
// keeps its existing photo (merges never overwrite).
if (photoFile && !created.merged?.had_photo) {
try {
await uploadRabbitPhoto(created.id, photoFile)
} catch {
navigate(`/herd/${created.id}`, {
replace: true,
state: { notice: 'Rabbit saved, but the photo didn’t upload. You can add it again from Edit.' },
state: {
notice: [notice, 'The photo didn’t upload. You can add it again from Edit.'].filter(Boolean).join(' '),
},
})
return
}
}
navigate(`/herd/${created.id}`, { replace: true })
navigate(`/herd/${created.id}`, { replace: true, state: notice ? { notice } : undefined })
}
return (
@ -224,6 +324,17 @@ function AddRabbit() {
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
{/* Shown on Pedigree and, once there's a photo, on Details too so the
subject's fields can be checked against it. */}
{scanStatus?.enabled && (step === 1 || (step === 2 && scanResult)) && (
<PedigreeScan
result={scanResult}
imageUrl={scanImageUrl}
onScanned={handleScanned}
confirmReplace={confirmReplaceWithScan}
/>
)}
{/* All three steps stay mounted and are hidden with CSS, so going back
(or jumping from Review via "Edit") keeps the pedigree tree's
internal state, the photo preview, and any open sections intact. */}
@ -246,6 +357,8 @@ function AddRabbit() {
Picking a rabbit from your herd fills in its ancestors automatically.
</p>
<PedigreeTree
key={pedigreeKey}
uncertainSlots={scanResult ? uncertainScanSlots(scanResult) : undefined}
subjectName={details.name.trim() || 'New Rabbit'}
initialState={pedigreeInitialState}
onChange={(fields, summary) => {
@ -286,6 +399,7 @@ function AddRabbit() {
genetics={genetics}
weightUnit={settings?.weight_unit}
onEdit={goToStep}
existingMatch={existingMatch}
/>
</div>
)}
@ -308,7 +422,7 @@ function AddRabbit() {
)}
{step === 3 && (
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
{saving ? 'Saving…' : existingMatch ? 'Update Existing Rabbit' : 'Save Rabbit'}
</Button>
)}
</StickyActionBar>

View File

@ -1,195 +0,0 @@
// TEMPORARY dev harness for validating OCR Pedigree Scanning, Phase 3.
// Not part of the app — wired to a throwaway /ocr-dev route. Delete this file
// and its route in App.jsx once Phase 3 is validated (same convention as the
// Phase 2 harness).
//
// Drops a real pedigree photo through the full Phase 3 pipeline in ONE OCR pass
// and shows every intermediate stage so you can eyeball where extraction works
// or breaks. The cells are reconstructed here from the exported layout/parse
// helpers in exactly the order extractPedigree() calls them, so the "Parsed
// cells" grid is byte-for-byte what extractPedigree() returns.
import { useState } from 'react'
import { preprocessImage } from '../lib/ocrPreprocess'
import { runOCR } from '../lib/ocrRun'
import { detectColumns, aggregateCellText } from '../lib/ocrLayout'
import { extractFromWords } from '../lib/ocrPipeline'
const CELL_ORDER = ['subject', 'sire', 'dam', 'sire_sire', 'sire_dam', 'dam_sire', 'dam_dam']
async function imageWidthOf(blob) {
const bitmap = await createImageBitmap(blob)
const { width, height } = bitmap
bitmap.close?.()
return { width, height }
}
export default function OcrDev() {
const [status, setStatus] = useState('idle')
const [error, setError] = useState(null)
const [origUrl, setOrigUrl] = useState(null)
const [procUrl, setProcUrl] = useState(null)
const [dims, setDims] = useState(null)
const [words, setWords] = useState([])
const [columns, setColumns] = useState(null)
const [cells, setCells] = useState(null)
const [elapsed, setElapsed] = useState(null)
async function handleFile(e) {
const file = e.target.files?.[0]
if (!file) return
setError(null)
setWords([])
setCells(null)
setColumns(null)
setOrigUrl(URL.createObjectURL(file))
const start = performance.now()
try {
setStatus('preprocessing')
const processed = await preprocessImage(file)
setProcUrl(URL.createObjectURL(processed))
const size = await imageWidthOf(processed)
setDims(size)
setStatus('scanning')
const { words: w } = await runOCR(processed)
setWords(w)
// Debug view: the raw adaptive columns (before subject-anchoring), so we
// can see where the gutters fell. The "Parsed cells" grid below uses the
// real pipeline core (extractFromWords) — same code extractPedigree runs.
setColumns(detectColumns(w, size.width).map((col) => aggregateCellText(col)))
setCells(extractFromWords(w, size.width))
setElapsed(Math.round(performance.now() - start))
setStatus('done')
} catch (err) {
console.error(err)
setError(err?.message || String(err))
setStatus('error')
}
}
return (
<div className="mx-auto max-w-5xl p-6 font-body text-text-primary">
<h1 className="mb-1 font-display text-2xl">OCR Phase 3 — dev harness</h1>
<p className="mb-4 text-sm text-text-secondary">
Temporary. Pick a pedigree photo; it runs preprocess → runOCR →
layout/parse in one OCR pass. Open DevTools → Network and confirm{' '}
<strong>no requests to any CDN</strong> (jsdelivr/unpkg) — all Tesseract
assets load from <code>/tesseract/</code>. For a true offline check, go
offline after the first load and re-run.
</p>
<input type="file" accept="image/*" onChange={handleFile} className="mb-4 block" />
<p className="mb-4 text-sm">
Status: <strong>{status}</strong>
{status === 'scanning' && ' (first scan loads ~9MB of WASM — can take 10–30s)'}
{dims && ` · preprocessed ${dims.width}×${dims.height}`}
{elapsed != null && status === 'done' && ` · ${elapsed}ms total`}
{words.length > 0 && ` · ${words.length} words (conf ≥ 60)`}
</p>
{error && (
<p className="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger">
Error: {error}
</p>
)}
{(origUrl || procUrl) && (
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
{origUrl && (
<figure>
<figcaption className="mb-1 text-xs text-text-secondary">Original</figcaption>
<img src={origUrl} alt="original" className="w-full border border-border" />
</figure>
)}
{procUrl && (
<figure>
<figcaption className="mb-1 text-xs text-text-secondary">
Preprocessed (binarized, ≤1600px) — this is what OCR sees
</figcaption>
<img src={procUrl} alt="preprocessed" className="w-full border border-border" />
</figure>
)}
</div>
)}
{cells && (
<>
<h2 className="mb-2 font-display text-lg">
Parsed cells (== extractPedigree() output)
</h2>
<div className="mb-6 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{CELL_ORDER.map((key) => (
<div key={key} className="rounded border border-border bg-surface-raised p-3">
<div className="mb-1 font-display text-sm">{key}</div>
<pre className="whitespace-pre-wrap text-xs text-text-secondary">
{JSON.stringify(cells[key], null, 2)}
</pre>
</div>
))}
</div>
</>
)}
{columns && (
<details className="mb-6" open>
<summary className="cursor-pointer font-display text-lg">
Detected columns ({columns.length}) — adaptive gutter split, left→right
</summary>
<p className="mb-2 mt-1 text-xs text-text-secondary">
Each box is one detected generation column (before boilerplate filtering
/ subject-anchoring). If these don't line up with subject / parents /
grandparents, the gutter thresholds need tuning.
</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{columns.map((text, i) => (
<div key={i} className="rounded border border-border bg-surface-raised p-3">
<div className="mb-1 font-display text-sm">column {i}</div>
<pre className="whitespace-pre-wrap text-[11px] text-text-secondary">
{text || '(empty)'}
</pre>
</div>
))}
</div>
</details>
)}
{words.length > 0 && (
<details className="mb-6">
<summary className="cursor-pointer font-display text-lg">
Raw word list ({words.length})
</summary>
<table className="mt-2 w-full text-left text-xs">
<thead>
<tr className="border-b border-border text-text-secondary">
<th className="py-1 pr-2">text</th>
<th className="py-1 pr-2">conf</th>
<th className="py-1 pr-2">x0</th>
<th className="py-1 pr-2">y0</th>
<th className="py-1 pr-2">x1</th>
<th className="py-1 pr-2">y1</th>
</tr>
</thead>
<tbody>
{words.map((w, i) => (
<tr key={i} className="border-b border-border/50">
<td className="py-1 pr-2">{w.text}</td>
<td className="py-1 pr-2">{Math.round(w.confidence)}</td>
<td className="py-1 pr-2">{w.bbox.x0}</td>
<td className="py-1 pr-2">{w.bbox.y0}</td>
<td className="py-1 pr-2">{w.bbox.x1}</td>
<td className="py-1 pr-2">{w.bbox.y1}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Link, useLocation, useParams } from 'react-router-dom'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
@ -8,7 +8,7 @@ import Button from '../components/Button'
import Silhouette from '../components/Silhouette'
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
import RabbitTimeline from '../components/RabbitTimeline'
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, SERVER_BASE } from '../api'
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
import { SEX_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
import { LOCI } from '../lib/genetics'
@ -144,6 +144,7 @@ function RabbitDetail() {
// Rabbit when the photo upload failed after the rabbit was created).
const notice = useLocation().state?.notice
const queryClient = useQueryClient()
const navigate = useNavigate()
const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null)
@ -174,6 +175,25 @@ function RabbitDetail() {
}
}
async function handleDelete() {
const confirmed = window.confirm(
`Permanently delete ${rabbit.name}? This can’t be undone.\n\n` +
'Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as unknown. ' +
'Its timeline events are deleted. To keep its history instead, use Archive.'
)
if (!confirmed) return
setUpdating(true)
setError(null)
try {
await deleteRabbit(id)
queryClient.invalidateQueries()
navigate('/herd', { replace: true })
} catch (err) {
setError(err.message)
setUpdating(false)
}
}
function handleArchive() {
if (window.confirm('Archive this rabbit? This marks it as deceased.')) {
patchStatus('deceased')
@ -229,6 +249,9 @@ function RabbitDetail() {
<Button variant="danger" size="sm" disabled={updating} onClick={handleArchive}>
Archive
</Button>
<Button variant="secondary" size="sm" disabled={updating} onClick={handleDelete} className="text-danger">
Delete
</Button>
</div>
}
/>

View File

@ -7,6 +7,10 @@ services:
# Settings. The old MYKINDLEBOX_PASSWORD/AUTH_PASSWORD env var is no
# longer used.
DATA_DIR: /app/data
# Pedigree scanning (Add Rabbit → "Scan a pedigree") sends the photo to
# the Claude API. Set ANTHROPIC_API_KEY in .env to enable it; without it
# the scan button is hidden and everything else works as before.
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
volumes:
- server-data:/app/data
restart: unless-stopped
@ -24,8 +28,8 @@ services:
client:
build: ./client
ports:
# Host port is configurable: set MYKINDLEBOX_PORT (e.g. in the same .env
# as MYKINDLEBOX_PASSWORD) to serve on a different port. Defaults to 8080.
# Host port is configurable: set MYKINDLEBOX_PORT (e.g. in .env) to serve
# on a different port. Defaults to 8080.
- "${MYKINDLEBOX_PORT:-8080}:80"
# Wait for the API to answer /health before starting nginx, so the first
# page load after `docker compose up` can't hit a 502 from a still-booting

View File

@ -13,6 +13,7 @@ import settingsRouter from './routes/settings';
import statsRouter from './routes/stats';
import exportRouter from './routes/export';
import importRouter from './routes/import';
import pedigreeScanRouter from './routes/pedigreeScan';
const app = express();
const PORT = Number(process.env.PORT) || 3001;
@ -50,6 +51,7 @@ app.use('/api/settings', requireAuth, settingsRouter);
app.use('/api/stats', requireAuth, statsRouter);
app.use('/api/export', requireAuth, exportRouter);
app.use('/api/import', requireAuth, importRouter);
app.use('/api/pedigree-scan', requireAuth, pedigreeScanRouter);
app.use((req: Request, res: Response, next: NextFunction) => {
res.status(404).json({ error: 'Not found' });

View File

@ -0,0 +1,182 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
// db/client opens (and migrates) the SQLite file under DATA_DIR at import
// time, so point it at a throwaway directory before loading anything that
// imports it.
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'mkb-ancestors-'));
const { db } = require('../db/client') as typeof import('../db/client');
const { rabbits } = require('../db/schema') as typeof import('../db/schema');
const { materializeAncestors, findExistingSubject, mergeIntoExisting } = require('./ancestors') as typeof import('./ancestors');
const { eq } = require('drizzle-orm') as typeof import('drizzle-orm');
type RabbitInsert = typeof rabbits.$inferInsert;
const manual = (name: string, extra: Record<string, unknown> = {}) =>
JSON.stringify({ name, farm: 'Test Farm', ear_number: '', weight_lbs: 3, dob: 'Unknown', ...extra });
function save(values: RabbitInsert) {
return db.transaction((tx) => {
materializeAncestors(tx, values);
return tx.insert(rabbits).values(values).returning().get();
});
}
// Mirrors POST /api/rabbits: top up an existing rabbit, else create.
function submit(values: RabbitInsert) {
return db.transaction((tx) => {
const existing = findExistingSubject(tx, values);
if (existing) return { id: existing.id, report: mergeIntoExisting(tx, existing, values) };
materializeAncestors(tx, values);
return { id: tx.insert(rabbits).values(values).returning().get().id, report: null };
});
}
const byId = (id: number) => db.select().from(rabbits).where(eq(rabbits.id, id)).get()!;
test('typed ancestors become linked reference rabbits', () => {
const subject = save({
name: 'Kit One',
sireNameText: manual('Big Buck', { ear_number: 'bb1', weight_lbs: 9.5, dob: '2022-01-02' }),
damNameText: manual('Nice Doe', { ear_number: 'ND1' }),
paternalGrandsireNameText: manual('Grandpa', { ear_number: 'GP1' }),
ppGranddamNameText: manual('Great Gran', { ear_number: 'GG1' }),
});
assert.ok(subject.sireId && subject.damId);
assert.equal(subject.sireNameText, null);
assert.equal(subject.paternalGrandsireNameText, null);
assert.equal(subject.ppGranddamNameText, null);
const sire = byId(subject.sireId!);
assert.equal(sire.name, 'Big Buck');
assert.equal(sire.sex, 'buck');
assert.equal(sire.status, 'other');
assert.equal(sire.earNumber, 'BB1');
assert.equal(sire.weightLbs, 9.5);
assert.equal(sire.dob, '2022-01-02');
assert.equal(sire.damId, null);
const grandpa = byId(sire.sireId!);
assert.equal(grandpa.name, 'Grandpa');
assert.equal(byId(grandpa.damId!).name, 'Great Gran');
assert.equal(byId(grandpa.damId!).sex, 'doe');
const dam = byId(subject.damId!);
assert.equal(dam.weightLbs, 3);
assert.equal(dam.dob, null); // "Unknown" isn't a date
});
test('a sibling reuses the ancestors already created', () => {
const before = db.select().from(rabbits).all().length;
const sibling = save({
name: 'Kit Two',
sireNameText: manual('Big Buck', { ear_number: 'BB1' }),
damNameText: manual('Nice Doe', { ear_number: 'nd1' }),
});
assert.equal(db.select().from(rabbits).all().length, before + 1); // only the sibling itself
const first = db.select().from(rabbits).where(eq(rabbits.name, 'Kit One')).get()!;
assert.equal(sibling.sireId, first.sireId);
assert.equal(sibling.damId, first.damId);
});
test('a line-bred pedigree repeating an animal creates it once', () => {
const subject = save({
name: 'Line Bred',
sireNameText: manual('Son', { ear_number: 'S1' }),
damNameText: manual('Daughter', { ear_number: 'D1' }),
paternalGrandsireNameText: manual('Patriarch', { ear_number: 'PAT' }),
maternalGrandsireNameText: manual('Patriarch', { ear_number: 'PAT' }),
});
const sire = byId(subject.sireId!);
const dam = byId(subject.damId!);
assert.equal(sire.sireId, dam.sireId);
});
test('name + farm matches only without a conflicting ear number; linked parents are untouched', () => {
const sameName = save({ name: 'Child A', sireNameText: manual('Common Name', { ear_number: 'X1' }) });
const otherEar = save({ name: 'Child B', sireNameText: manual('Common Name', { ear_number: 'X2' }) });
assert.notEqual(sameName.sireId, otherEar.sireId);
const linked = save({ name: 'Child C', sireId: sameName.sireId, sireNameText: manual('Ignored') });
assert.equal(linked.sireId, sameName.sireId);
assert.equal(db.select().from(rabbits).where(eq(rabbits.name, 'Ignored')).all().length, 0);
});
test('an Unknown parent keeps grandparent text on the rabbit', () => {
const subject = save({ name: 'Partial', paternalGrandsireNameText: manual('Lonely Grandsire') });
assert.equal(subject.sireId ?? null, null);
assert.ok(subject.paternalGrandsireNameText);
});
test('adding an existing rabbit again fills gaps and adds missing ancestors, never overwrites', () => {
const first = submit({ name: 'Repeat', farm: 'Home', earNumber: 'RP1', breed: 'Holland Lop', sex: 'doe' });
assert.equal(first.report, null);
const again = submit({
name: 'Repeat',
farm: 'Home',
earNumber: 'rp1',
breed: 'Mini Rex', // already set: must not change
colorName: 'Broken Black',
weightLbs: 3.5,
sireNameText: manual('Repeat Sire', { ear_number: 'RS1' }),
paternalGrandsireNameText: manual('Repeat Grandsire', { ear_number: 'RG1' }),
});
assert.equal(again.id, first.id);
const row = byId(first.id);
assert.equal(row.breed, 'Holland Lop');
assert.equal(row.colorName, 'Broken Black');
assert.equal(row.weightLbs, 3.5);
assert.equal(byId(row.sireId!).name, 'Repeat Sire');
assert.equal(byId(byId(row.sireId!).sireId!).name, 'Repeat Grandsire');
assert.equal(again.report!.ancestorsAdded, 2);
assert.deepEqual(again.report!.filled.sort(), ['colorName', 'weightLbs']);
// A third pass adds the sire's dam to the existing sire record.
const third = submit({
name: 'Repeat',
earNumber: 'RP1',
farm: 'Home',
sireNameText: manual('Repeat Sire', { ear_number: 'RS1', dob: '2021-05-06' }),
paternalGranddamNameText: manual('Repeat Granddam', { ear_number: 'RD1' }),
});
const sire = byId(byId(first.id).sireId!);
assert.equal(sire.dob, '2021-05-06');
assert.equal(byId(sire.damId!).name, 'Repeat Granddam');
assert.equal(third.report!.ancestorsUpdated, 1);
});
test('adding a rabbit that exists only as a reference ancestor promotes it', () => {
const kit = submit({ name: 'Promo Kit', damNameText: manual('Promo Doe', { ear_number: 'PD1' }) });
const damId = byId(kit.id).damId!;
assert.equal(byId(damId).status, 'other');
const added = submit({ name: 'Promo Doe', farm: 'Test Farm', earNumber: 'PD1', status: 'active', sex: 'doe' });
assert.equal(added.id, damId);
assert.equal(byId(damId).status, 'active');
});
test('a different existing parent is kept and reported as a conflict', () => {
submit({ name: 'Conflicted', earNumber: 'CF1', sireNameText: manual('Real Sire', { ear_number: 'RSX' }) });
const again = submit({ name: 'Conflicted', earNumber: 'CF1', sireNameText: manual('Other Sire', { ear_number: 'OSX' }) });
assert.equal(byId(byId(again.id).sireId!).name, 'Real Sire');
assert.equal(again.report!.conflicts.length, 1);
});
test('when an ear number is shared, the herd rabbit wins over a reference record', () => {
const ref = db.insert(rabbits).values({ name: 'Dup', earNumber: 'DUP1', status: 'other' }).returning().get();
const herd = db.insert(rabbits).values({ name: 'Dup', earNumber: 'DUP1', status: 'active' }).returning().get();
const kit = submit({ name: 'Dup Kit', sireNameText: manual('Dup', { ear_number: 'DUP1' }) });
assert.equal(byId(kit.id).sireId, herd.id);
assert.notEqual(byId(kit.id).sireId, ref.id);
});
test('same name and farm but a different DOB is a different rabbit', () => {
const a = submit({ name: 'Doe 1', farm: 'Kits', dob: '2025-01-01' });
const b = submit({ name: 'Doe 1', farm: 'Kits', dob: '2025-06-01' });
assert.notEqual(a.id, b.id);
});

View File

@ -0,0 +1,344 @@
import { and, asc, eq, isNull, or, sql } from 'drizzle-orm';
import { rabbits } from '../db/schema';
// Ancestor materialization and "add again = top up" merging.
//
// When a rabbit is saved from Add Rabbit, every ancestor entered as text
// (typed in or scanned) becomes a real rabbit record linked through
// sire_id/dam_id, so it can be reused. Created ancestors get status 'other'
// (they show under Archived, not the active herd or dashboard counts).
//
// Matching (reuse instead of duplicate) — `findExisting`: a rabbit with the
// same ear number, else the same name + farm, provided the ear number, sex and
// DOB don't contradict each other. When several share an ear number, the
// active herd rabbit wins over a reference record.
//
// Merging never overwrites: a matched rabbit only gets its *empty* fields
// filled, missing parents linked/created, and the same applied up the tree.
// If a matched rabbit already has a parent that is a different animal from the
// one entered, the existing record wins and that branch is reported as a
// conflict.
type RabbitRow = typeof rabbits.$inferSelect;
type RabbitInsert = typeof rabbits.$inferInsert;
type Direction = 'sire' | 'dam';
type TextKey =
| 'sireNameText'
| 'damNameText'
| 'paternalGrandsireNameText'
| 'paternalGranddamNameText'
| 'maternalGrandsireNameText'
| 'maternalGranddamNameText'
| 'ppGrandsireNameText'
| 'ppGranddamNameText'
| 'pmGrandsireNameText'
| 'pmGranddamNameText'
| 'mpGrandsireNameText'
| 'mpGranddamNameText'
| 'mmGrandsireNameText'
| 'mmGranddamNameText';
const TEXT_KEY_BY_PATH: Record<string, TextKey> = {
sire: 'sireNameText',
dam: 'damNameText',
'sire,sire': 'paternalGrandsireNameText',
'sire,dam': 'paternalGranddamNameText',
'dam,sire': 'maternalGrandsireNameText',
'dam,dam': 'maternalGranddamNameText',
'sire,sire,sire': 'ppGrandsireNameText',
'sire,sire,dam': 'ppGranddamNameText',
'sire,dam,sire': 'pmGrandsireNameText',
'sire,dam,dam': 'pmGranddamNameText',
'dam,sire,sire': 'mpGrandsireNameText',
'dam,sire,dam': 'mpGranddamNameText',
'dam,dam,sire': 'mmGrandsireNameText',
'dam,dam,dam': 'mmGranddamNameText',
};
const TEXT_KEYS = new Set<string>(Object.values(TEXT_KEY_BY_PATH));
const MAX_DEPTH = 3; // great-grandparents; their parents aren't captured
const DEFAULT_WEIGHT_LBS = 3; // user rule, mirrors client MANUAL_ANCESTOR_DEFAULTS
// Subject fields that a merge never fills from the form: identity/links and
// status are handled explicitly, the rest are server-managed.
const NON_FILLABLE = new Set(['id', 'sireId', 'damId', 'status', 'createdAt', 'updatedAt', 'photoPath']);
interface ParsedAncestor {
name: string;
farm: string | null;
earNumber: string | null;
weightLbs: number;
dob: string | null;
}
/** What a merge or materialization did, returned to the client as `merged`. */
export interface MergeReport {
filled: string[];
ancestorsAdded: number;
ancestorsUpdated: number;
conflicts: string[];
hadPhoto: boolean;
}
function emptyReport(): MergeReport {
return { filled: [], ancestorsAdded: 0, ancestorsUpdated: 0, conflicts: [], hadPhoto: false };
}
function blankToNull(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || (typeof value === 'string' && value.trim() === '');
}
function pathLabel(path: Direction[]): string {
return path.map((d) => (d === 'sire' ? 'Sire' : 'Dam')).join("'s ");
}
// Manual ancestors are stored as JSON ({name, farm, ear_number, weight_lbs,
// dob}, see client lib/pedigree.js serializeManualAncestor); older entries
// may be a plain name.
function parseAncestorText(text: string | null | undefined): ParsedAncestor | null {
if (!text?.trim()) return null;
let raw: Record<string, unknown> = { name: text };
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') raw = parsed;
} catch {
// plain-text legacy entry
}
const name = blankToNull(raw.name);
if (!name) return null;
const weight = Number(raw.weight_lbs);
const dob = blankToNull(raw.dob);
return {
name,
farm: blankToNull(raw.farm),
earNumber: blankToNull(raw.ear_number)?.toUpperCase() ?? null,
weightLbs: Number.isFinite(weight) && weight > 0 ? weight : DEFAULT_WEIGHT_LBS,
dob: dob && /^\d{4}-\d{2}-\d{2}$/.test(dob) ? dob : null,
};
}
// Anything with select/insert/update works — the module's `db` or a transaction.
type Db = Pick<typeof import('../db/client').db, 'select' | 'insert' | 'update'>;
interface Identity {
name: string;
farm: string | null;
earNumber: string | null;
sex?: string | null;
dob?: string | null;
}
function compatible(row: RabbitRow, who: Identity): boolean {
const ear = blankToNull(row.earNumber)?.toUpperCase() ?? null;
if (ear && who.earNumber && ear !== who.earNumber) return false;
if (who.sex && who.sex !== 'unknown' && row.sex !== 'unknown' && row.sex !== who.sex) return false;
if (who.dob && row.dob && row.dob !== who.dob) return false;
return true;
}
export function findExisting(tx: Db, who: Identity): RabbitRow | null {
if (who.earNumber) {
const byEar = tx
.select()
.from(rabbits)
.where(sql`upper(trim(${rabbits.earNumber})) = ${who.earNumber}`)
// Prefer a real herd rabbit over a reference record, then the oldest.
.orderBy(sql`${rabbits.status} = 'other'`, asc(rabbits.id))
.all()
.filter((row) => compatible(row, who));
if (byEar.length > 0) return byEar[0];
}
const farmMatch = who.farm
? sql`lower(trim(${rabbits.farm})) = ${who.farm.toLowerCase()}`
: or(isNull(rabbits.farm), sql`trim(${rabbits.farm}) = ''`);
const byName = tx
.select()
.from(rabbits)
.where(and(sql`lower(trim(${rabbits.name})) = ${who.name.toLowerCase()}`, farmMatch))
.all()
.filter((row) => compatible(row, who));
return byName.length === 1 ? byName[0] : null; // several same-name rabbits: don't guess
}
function fetchRow(tx: Db, id: number): RabbitRow | undefined {
return tx.select().from(rabbits).where(eq(rabbits.id, id)).get();
}
// Fill only the empty columns of `row` from `patch`; returns the filled keys.
function fillMissing(tx: Db, row: RabbitRow, patch: Partial<RabbitInsert>): string[] {
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch)) {
if (isEmpty(value)) continue;
const current = (row as Record<string, unknown>)[key];
const missing = isEmpty(current) || (key === 'sex' && current === 'unknown' && value !== 'unknown');
if (missing) updates[key] = value;
}
const keys = Object.keys(updates);
if (keys.length > 0) {
tx.update(rabbits)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(eq(rabbits.id, row.id))
.run();
Object.assign(row, updates);
}
return keys;
}
function ancestorPatch(a: ParsedAncestor): Partial<RabbitInsert> {
return { farm: a.farm, earNumber: a.earNumber, dob: a.dob, weightLbs: a.weightLbs };
}
function sexForPath(path: Direction[]): 'buck' | 'doe' {
return path[path.length - 1] === 'sire' ? 'buck' : 'doe';
}
// Resolve the ancestor entered at `path` to a rabbit id: reuse (and top up) a
// matching rabbit, or create one with its own parents.
function resolveAncestor(tx: Db, values: RabbitInsert, path: Direction[], report: MergeReport): number | null {
const ancestor = parseAncestorText(values[TEXT_KEY_BY_PATH[path.join(',')]]);
if (!ancestor) return null;
const sex = sexForPath(path);
const existing = findExisting(tx, { ...ancestor, sex, dob: ancestor.dob });
if (existing) {
if (fillMissing(tx, existing, ancestorPatch(ancestor)).length > 0) report.ancestorsUpdated += 1;
attachMissingParents(tx, values, existing, path, report);
return existing.id;
}
// Parents first, so an animal repeated higher up the tree (line breeding)
// is created once and then found by findExisting on its second appearance.
const deeper = path.length < MAX_DEPTH;
const sireId = deeper ? resolveAncestor(tx, values, [...path, 'sire'], report) : null;
const damId = deeper ? resolveAncestor(tx, values, [...path, 'dam'], report) : null;
const created = tx
.insert(rabbits)
.values({
name: ancestor.name,
farm: ancestor.farm,
earNumber: ancestor.earNumber,
sex,
dob: ancestor.dob,
weightLbs: ancestor.weightLbs,
status: 'other',
sireId,
damId,
})
.returning({ id: rabbits.id })
.get();
report.ancestorsAdded += 1;
return created.id;
}
// For a rabbit already in the DB sitting at `path` in the entered pedigree,
// link/create parents it's missing and top up the ones it has (when they're
// the same animal as entered).
function attachMissingParents(
tx: Db,
values: RabbitInsert,
row: RabbitRow,
path: Direction[],
report: MergeReport,
): void {
if (path.length >= MAX_DEPTH) return;
for (const side of ['sire', 'dam'] as const) {
const childPath = [...path, side];
const entered = parseAncestorText(values[TEXT_KEY_BY_PATH[childPath.join(',')]]);
if (!entered) continue;
const fkKey = side === 'sire' ? 'sireId' : 'damId';
const linkedId = row[fkKey];
if (linkedId == null) {
const id = resolveAncestor(tx, values, childPath, report);
if (id != null && id !== row.id) {
tx.update(rabbits).set({ [fkKey]: id, updatedAt: new Date().toISOString() }).where(eq(rabbits.id, row.id)).run();
row[fkKey] = id;
}
continue;
}
const linked = fetchRow(tx, linkedId);
if (!linked) continue;
const sameAnimal =
linked.name.trim().toLowerCase() === entered.name.toLowerCase() ||
(entered.earNumber != null && blankToNull(linked.earNumber)?.toUpperCase() === entered.earNumber);
if (!sameAnimal) {
report.conflicts.push(`${pathLabel(childPath)}: kept ${linked.name} (you entered ${entered.name})`);
continue;
}
if (fillMissing(tx, linked, ancestorPatch(entered)).length > 0) report.ancestorsUpdated += 1;
attachMissingParents(tx, values, linked, childPath, report);
}
}
// New rabbit: replace each parent branch entered as text with a sire_id/dam_id
// link and clear that branch's now-redundant text columns. Mutates `values`.
// A parent left Unknown keeps any grandparent text as-is.
export function materializeAncestors(tx: Db, values: RabbitInsert): MergeReport {
const report = emptyReport();
for (const side of ['sire', 'dam'] as const) {
const fkKey = side === 'sire' ? 'sireId' : 'damId';
if (values[fkKey] != null) continue; // already linked to a herd rabbit
const id = resolveAncestor(tx, values, [side], report);
if (id == null) continue;
values[fkKey] = id;
for (const [pathKey, textKey] of Object.entries(TEXT_KEY_BY_PATH)) {
if (pathKey === side || pathKey.startsWith(`${side},`)) values[textKey] = null;
}
}
return report;
}
/** The existing rabbit an Add Rabbit submission refers to, if any. */
export function findExistingSubject(tx: Db, values: RabbitInsert): RabbitRow | null {
const name = blankToNull(values.name);
if (!name) return null;
return findExisting(tx, {
name,
farm: blankToNull(values.farm),
earNumber: blankToNull(values.earNumber)?.toUpperCase() ?? null,
sex: values.sex ?? null,
dob: blankToNull(values.dob),
});
}
// "Add again" on an existing rabbit: fill its empty fields, adopt the entered
// status if it was only a reference ancestor, link a parent picked from the
// herd if it had none, and add/top up ancestors up the tree.
export function mergeIntoExisting(tx: Db, row: RabbitRow, values: RabbitInsert): MergeReport {
const report = emptyReport();
report.hadPhoto = Boolean(row.photoPath);
const patch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values)) {
if (!NON_FILLABLE.has(key) && !TEXT_KEYS.has(key)) patch[key] = value;
}
if (typeof patch.earNumber === 'string') patch.earNumber = patch.earNumber.trim().toUpperCase();
report.filled = fillMissing(tx, row, patch as Partial<RabbitInsert>);
if (row.status === 'other' && values.status && values.status !== 'other') {
tx.update(rabbits).set({ status: values.status }).where(eq(rabbits.id, row.id)).run();
row.status = values.status;
report.filled.push('status');
}
for (const side of ['sire', 'dam'] as const) {
const fkKey = side === 'sire' ? 'sireId' : 'damId';
const picked = values[fkKey];
if (picked != null && row[fkKey] == null && picked !== row.id) {
tx.update(rabbits).set({ [fkKey]: picked }).where(eq(rabbits.id, row.id)).run();
row[fkKey] = picked;
report.filled.push(fkKey);
}
}
attachMissingParents(tx, values, row, [], report);
return report;
}

View File

@ -0,0 +1,221 @@
import Anthropic from '@anthropic-ai/sdk';
import { betaZodOutputFormat } from '@anthropic-ai/sdk/helpers/beta/zod';
import { z } from 'zod';
// Pedigree scanning: a photo of any pedigree layout (horizontal, vertical,
// printed, handwritten, color or B&W) goes to a Claude vision model, which
// returns the subject + 14 ancestors as schema-validated JSON. Position-based
// OCR (the earlier Tesseract pipeline) couldn't cope with layout variety; the
// model works out relationships from labels and tree structure instead.
//
// Enabled only when ANTHROPIC_API_KEY is set (docker-compose passes it through
// from .env). The image is sent to the API and never stored by the app.
const MODEL = 'claude-opus-5-5';
const FIELD_KEYS = ['name', 'farm', 'ear_number', 'dob', 'weight_lbs'] as const;
export type ScanField = (typeof FIELD_KEYS)[number];
const animalFields = {
name: z.string().nullable().describe('Rabbit name without the farm prefix, exactly as written'),
farm: z.string().nullable().describe('Rabbitry / farm prefix, e.g. "Cody\'s" or "Peachy Buns\'"'),
ear_number: z.string().nullable().describe('Tattoo / ear number'),
dob: z.string().nullable().describe('Date of birth as YYYY-MM-DD, only if a full date is legible'),
weight_lbs: z.number().nullable().describe('Weight in decimal pounds'),
uncertain: z
.array(z.enum(FIELD_KEYS))
.describe('Fields whose value is a best guess, or that look filled in but are illegible'),
};
// Nested exactly like a pedigree so the model fills relationships, not slots.
const GreatGrandparent = z.object(animalFields);
const Grandparent = z.object({ ...animalFields, sire: GreatGrandparent, dam: GreatGrandparent });
const Parent = z.object({ ...animalFields, sire: Grandparent, dam: Grandparent });
const Subject = z.object({
...animalFields,
sex: z.enum(['buck', 'doe', 'unknown']),
breed: z.string().nullable(),
color: z.string().nullable().describe('Color / variety of the subject rabbit'),
sire: Parent,
dam: Parent,
});
const ScanOutput = z.object({
is_pedigree: z.boolean().describe('False if the image is not a rabbit pedigree'),
subject: Subject,
notes: z
.string()
.nullable()
.describe('One short sentence on anything that limited the reading (cut off, blurry, glare), else null'),
});
const SYSTEM_PROMPT = `You transcribe photographed rabbit pedigrees into structured data for a rabbitry management app.
A pedigree shows one rabbit (the subject) and its ancestors: sire (father) and dam (mother), their sires and dams (grandparents), and usually the great-grandparents. Breeders format pedigrees very differently:
- Horizontal trees (generations left to right, sire's branch above the dam's), vertical trees (generations top to bottom or bottom to top, sire's branch on the left), boxed grids, or plain lists.
- Printed forms, software printouts, or handwriting; color-coded boxes (often blue for sires, pink for dams) or black and white.
- Labels such as "Sire", "Dam", "Sire's Sire", "Dam's Dam", "GS"/"GD" (grand sire/dam), "GGS"/"GGD", or "S"/"D".
Work out each animal's place from the labels and the connecting structure, not from page position alone. The subject is the rabbit the pedigree is for: usually the entry labelled with the rabbit's name at the root of the tree or at the top of the form. A sire is always a buck and a dam always a doe; each animal's sire and dam are the two entries directly linked to it in the next generation.
For each animal capture only:
- name and farm: many names start with a rabbitry prefix, often possessive ("Cody's Black Betty" → farm "Cody's", name "Black Betty"; "Peachy Buns' Hoppin-pop" → farm "Peachy Buns'", name "Hoppin-pop"). Split the prefix into farm. With no prefix, farm is null unless a breeder or rabbitry is written for that animal.
- ear_number: the tattoo or ear number ("Tattoo", "Ear #", "T:").
- dob: return YYYY-MM-DD only when the full date is legible. Read numeric dates month-first (US) unless the document clearly uses another order. Otherwise null.
- weight_lbs: convert to decimal pounds ("8 lb 4 oz" → 8.25, "142 oz" → 8.875). A number with no unit is a weight only if it sits in a weight column or after a weight label.
For the subject also capture sex (from "Buck"/"Doe"/"Sex"), breed, and color/variety. Ignore everything else (registration and grand champion numbers, ancestor colors, breeder addresses, signatures).
Accuracy rules:
- Transcribe exactly as written. Keep the breeder's spelling and capitalization; do not correct or complete names.
- Never invent or infer values, and never copy a value from one animal to another. Blank boxes and unknown ancestors get null fields.
- For handwriting or poor image quality, give your best reading and list the field in "uncertain". If a field is clearly filled in but unreadable, leave it null and still list it in "uncertain".
- If the image is not a rabbit pedigree, set is_pedigree to false and leave every field null.`;
export class PedigreeScanError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
export function isPedigreeScanEnabled(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
}
let client: Anthropic | null = null;
function getClient(): Anthropic {
// A slow high-effort read can take a minute; nginx allows 180s (nginx.conf).
client ??= new Anthropic({ timeout: 150_000, maxRetries: 1 });
return client;
}
type RawAnimal = z.infer<typeof GreatGrandparent>;
export interface ScannedAnimal {
name: string | null;
farm: string | null;
ear_number: string | null;
dob: string | null;
weight_lbs: number | null;
uncertain: ScanField[];
}
export interface PedigreeScanResult {
subject: ScannedAnimal & { sex: 'buck' | 'doe' | 'unknown'; breed: string | null; color_name: string | null };
/** Keyed by the client's pedigree slot names (client/src/lib/pedigree.js). */
ancestors: Record<string, ScannedAnimal>;
notes: string | null;
}
function cleanText(value: string | null): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function cleanDate(value: string | null): string | null {
const text = cleanText(value);
if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) return null;
const date = new Date(`${text}T00:00:00Z`);
return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text ? null : text;
}
function cleanAnimal(raw: RawAnimal): ScannedAnimal {
const weight = raw.weight_lbs;
return {
name: cleanText(raw.name),
farm: cleanText(raw.farm),
ear_number: cleanText(raw.ear_number)?.toUpperCase() ?? null,
dob: cleanDate(raw.dob),
// Guard against a misread (a year or ear number taken for a weight).
weight_lbs: weight != null && Number.isFinite(weight) && weight > 0 && weight < 40 ? weight : null,
uncertain: [...new Set(raw.uncertain)],
};
}
// Nested model output → the flat slot map PedigreeTree uses.
function flattenAncestors(subject: z.infer<typeof Subject>): Record<string, ScannedAnimal> {
const { sire, dam } = subject;
return {
sire: cleanAnimal(sire),
dam: cleanAnimal(dam),
paternal_grandsire: cleanAnimal(sire.sire),
paternal_granddam: cleanAnimal(sire.dam),
maternal_grandsire: cleanAnimal(dam.sire),
maternal_granddam: cleanAnimal(dam.dam),
pp_grandsire: cleanAnimal(sire.sire.sire),
pp_granddam: cleanAnimal(sire.sire.dam),
pm_grandsire: cleanAnimal(sire.dam.sire),
pm_granddam: cleanAnimal(sire.dam.dam),
mp_grandsire: cleanAnimal(dam.sire.sire),
mp_granddam: cleanAnimal(dam.sire.dam),
mm_grandsire: cleanAnimal(dam.dam.sire),
mm_granddam: cleanAnimal(dam.dam.dam),
};
}
export async function scanPedigreeImage(
image: Buffer,
mediaType: 'image/jpeg' | 'image/png' | 'image/webp',
): Promise<PedigreeScanResult> {
let response;
try {
response = await getClient().beta.messages.parse({
model: MODEL,
max_tokens: 16000,
// On a policy decline, re-run on Anthropic's recommended fallback model
// instead of failing the scan.
betas: ['server-side-fallback-2026-07-01'],
fallbacks: 'default',
output_config: { effort: 'high', format: betaZodOutputFormat(ScanOutput) },
system: SYSTEM_PROMPT,
messages: [
{
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: image.toString('base64') } },
{ type: 'text', text: 'Transcribe this pedigree.' },
],
},
],
});
} catch (err) {
if (err instanceof Anthropic.AuthenticationError || err instanceof Anthropic.PermissionDeniedError) {
throw new PedigreeScanError('The scanning API key was rejected. Check ANTHROPIC_API_KEY on the server.', 502);
}
if (err instanceof Anthropic.RateLimitError) {
throw new PedigreeScanError('Scanning is busy right now. Try again in a minute.', 429);
}
if (err instanceof Anthropic.APIConnectionError) {
throw new PedigreeScanError('Couldn’t reach the scanning service. Check the server’s internet connection.', 502);
}
if (err instanceof Anthropic.APIError) {
console.error('Pedigree scan API error', err.status, err.message);
throw new PedigreeScanError('The scanning service returned an error. Try again.', 502);
}
throw err;
}
if (response.stop_reason === 'refusal') {
throw new PedigreeScanError('This image couldn’t be scanned. Try a clearer photo of just the pedigree.', 422);
}
const parsed = response.parsed_output;
if (response.stop_reason === 'max_tokens' || !parsed) {
throw new PedigreeScanError('The scan didn’t finish. Try again.', 502);
}
if (!parsed.is_pedigree) {
throw new PedigreeScanError('That doesn’t look like a rabbit pedigree. Try another photo.', 422);
}
const subject = parsed.subject;
return {
subject: {
...cleanAnimal(subject),
sex: subject.sex,
breed: cleanText(subject.breed),
color_name: cleanText(subject.color),
},
ancestors: flattenAncestors(subject),
notes: cleanText(parsed.notes),
};
}

View File

@ -9,12 +9,14 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^1.3.0",
"better-sqlite3": "^12.11.1",
"cheerio": "^1.2.0",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0"
"multer": "^2.2.0",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
@ -27,6 +29,37 @@
"typescript": "^6.0.3"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz",
"integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.1",
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@ -912,6 +945,21 @@
"node": ">=18"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz",
"integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@ -2336,6 +2384,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@ -2617,6 +2671,19 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3287,6 +3354,16 @@
"source-map": "^0.6.0"
}
},
"node_modules/standardwebhooks": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -3359,6 +3436,12 @@
"node": ">=0.6"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
@ -3520,6 +3603,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -14,12 +14,14 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@anthropic-ai/sdk": "^1.3.0",
"better-sqlite3": "^12.11.1",
"cheerio": "^1.2.0",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0"
"multer": "^2.2.0",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",

View File

@ -0,0 +1,86 @@
import { Router } from 'express';
import { sql } from 'drizzle-orm';
import multer from 'multer';
import { db } from '../db/client';
import { rabbits } from '../db/schema';
import { isPedigreeScanEnabled, PedigreeScanError, scanPedigreeImage } from '../lib/pedigreeScan';
const router = Router();
const MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'] as const);
type MediaType = 'image/jpeg' | 'image/png' | 'image/webp';
// In memory: the image is forwarded to the API and discarded, never written to
// disk. The client downsizes to ~2400px JPEG first, so real uploads are well
// under the limit.
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}).single('image');
// Lets the client decide whether to show the "Scan a pedigree" button.
router.get('/', (req, res) => {
res.json({ enabled: isPedigreeScanEnabled() });
});
// Confident herd match for a scanned sire/dam (FEATURE_OCR_Pedigree_Scan.md
// Q6): an exact ear number wins; otherwise exactly one exact-name match.
// Anything looser stays a manual entry the user can link by hand.
function findHerdMatch(name: string | null, earNumber: string | null) {
const columns = { id: rabbits.id, name: rabbits.name };
if (earNumber) {
const byEar = db
.select(columns)
.from(rabbits)
.where(sql`upper(${rabbits.earNumber}) = ${earNumber.toUpperCase()}`)
.limit(2)
.all();
if (byEar.length === 1) return byEar[0];
}
if (name) {
const byName = db
.select(columns)
.from(rabbits)
.where(sql`lower(${rabbits.name}) = ${name.toLowerCase()}`)
.limit(2)
.all();
if (byName.length === 1) return byName[0];
}
return null;
}
router.post('/', (req, res, next) => {
if (!isPedigreeScanEnabled()) {
res.status(503).json({ error: 'Pedigree scanning isn’t set up on this server.' });
return;
}
upload(req, res, async (uploadErr) => {
if (uploadErr) {
res.status(400).json({ error: uploadErr.message });
return;
}
const file = req.file;
if (!file || !MEDIA_TYPES.has(file.mimetype as MediaType)) {
res.status(400).json({ error: 'Upload a JPEG, PNG, or WebP photo of the pedigree.' });
return;
}
try {
const result = await scanPedigreeImage(file.buffer, file.mimetype as MediaType);
const matches = {
sire: findHerdMatch(result.ancestors.sire.name, result.ancestors.sire.ear_number),
dam: findHerdMatch(result.ancestors.dam.name, result.ancestors.dam.ear_number),
};
res.json({ ...result, matches });
} catch (err) {
if (err instanceof PedigreeScanError) {
res.status(err.status).json({ error: err.message });
return;
}
next(err);
}
});
});
export default router;

View File

@ -3,8 +3,14 @@ import { and, or, asc, gte, eq, sql, SQL, AnyColumn, getTableColumns } from 'dri
import multer from 'multer';
import path from 'path';
import { db, uploadsDir } from '../db/client';
import { rabbits } from '../db/schema';
import { rabbits, rabbitEvents, litters, showEventEntries } from '../db/schema';
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
import {
findExistingSubject,
materializeAncestors,
mergeIntoExisting,
MergeReport,
} from '../lib/ancestors';
const router = Router();
@ -105,6 +111,20 @@ function fetchRabbitById(id: number): RabbitRow | undefined {
return db.select().from(rabbits).where(eq(rabbits.id, id)).get();
}
// Manual-ancestor JSON for a resolved node (see client lib/pedigree.js).
// Freeform nodes already hold their stored text.
function snapshotText(node: AncestorNode | null): string | null {
if (!node) return null;
if (node.id == null) return node.name;
return JSON.stringify({
name: node.name ?? '',
farm: node.farm ?? '',
ear_number: node.ear_number ?? '',
weight_lbs: node.weight_lbs ?? 3,
dob: node.dob ?? 'Unknown',
});
}
function dbAncestorNode(ancestor: RabbitRow): AncestorNode {
return {
id: ancestor.id,
@ -409,9 +429,46 @@ router.post('/', (req, res) => {
return;
}
const created = db.insert(rabbits).values(values).returning().get();
// Adding a rabbit that already exists (same ear number, or same name + farm)
// tops up that record instead of duplicating it; otherwise ancestors entered
// as text become linked reference rabbits. See lib/ancestors.ts. One
// transaction so a failure can't leave half-made ancestor records behind.
const outcome = db.transaction((tx) => {
const existing = findExistingSubject(tx, values);
if (existing) {
const report = mergeIntoExisting(tx, existing, values);
const row = tx.select().from(rabbits).where(eq(rabbits.id, existing.id)).get()!;
return { row, merged: report };
}
materializeAncestors(tx, values);
return { row: tx.insert(rabbits).values(values).returning().get(), merged: null };
});
res.status(201).json(toSnakeCase(created));
res
.status(outcome.merged ? 200 : 201)
.json({ ...toSnakeCase(outcome.row), merged: outcome.merged && mergeReportJson(outcome.merged) });
});
function mergeReportJson(report: MergeReport) {
return {
filled: Object.keys(toSnakeCase(Object.fromEntries(report.filled.map((key) => [key, true])))),
ancestors_added: report.ancestorsAdded,
ancestors_updated: report.ancestorsUpdated,
conflicts: report.conflicts,
had_photo: report.hadPhoto,
};
}
// Lets the Add Rabbit review step say up front that saving will top up an
// existing rabbit. Same matching as POST / uses.
router.post('/find-existing', (req, res) => {
const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert;
const existing = findExistingSubject(db, values);
res.json(
existing
? toSnakeCase({ id: existing.id, name: existing.name, farm: existing.farm, earNumber: existing.earNumber, status: existing.status })
: null,
);
});
router.patch('/:id', (req, res) => {
@ -450,6 +507,20 @@ router.delete('/:id', (req, res) => {
return;
}
// Show entries are history the user would lose silently — make them remove
// those first (the UI suggests Archive instead).
const showEntryCount = db
.select({ id: showEventEntries.id })
.from(showEventEntries)
.where(eq(showEventEntries.rabbitId, id))
.all().length;
if (showEntryCount > 0) {
res.status(409).json({
error: `${existing.name} is entered in ${showEntryCount} show${showEntryCount === 1 ? '' : 's'}. Remove those entries first, or archive the rabbit instead.`,
});
return;
}
// Chain-authority: when a rabbit is deleted, its offspring's link to it is
// broken, so snapshot the deleted rabbit (and the two generations above it
// that the offspring used to see through it) into the offspring's freeform
@ -458,11 +529,14 @@ router.delete('/:id', (req, res) => {
// text. All offspring of a given rabbit share the same snapshot, so it's
// computed once. Wrapped in a transaction with the delete so a partial failure
// can't leave dangling FKs.
const nameAt = (path: Direction[]) => resolveAncestorNode(existing, path)?.name ?? null;
// Snapshots keep the full manual-ancestor JSON (name, farm, ear #, weight,
// DOB), not just a name, so the entry can be matched/re-linked later.
const nameAt = (path: Direction[]) => snapshotText(resolveAncestorNode(existing, path));
const self = snapshotText(dbAncestorNode(existing));
const sireSnapshot = {
sireId: null,
sireNameText: existing.name,
sireNameText: self,
paternalGrandsireNameText: nameAt(['sire']),
paternalGranddamNameText: nameAt(['dam']),
ppGrandsireNameText: nameAt(['sire', 'sire']),
@ -473,7 +547,7 @@ router.delete('/:id', (req, res) => {
const damSnapshot = {
damId: null,
damNameText: existing.name,
damNameText: self,
maternalGrandsireNameText: nameAt(['sire']),
maternalGranddamNameText: nameAt(['dam']),
mpGrandsireNameText: nameAt(['sire', 'sire']),
@ -485,6 +559,12 @@ router.delete('/:id', (req, res) => {
db.transaction((tx) => {
tx.update(rabbits).set(sireSnapshot).where(eq(rabbits.sireId, id)).run();
tx.update(rabbits).set(damSnapshot).where(eq(rabbits.damId, id)).run();
// SQLite FK enforcement is off, so nothing cascades on its own: litters
// keep their dates/kit counts with that parent shown as unknown, and the
// rabbit's own timeline events go with it.
tx.update(litters).set({ sireId: null }).where(eq(litters.sireId, id)).run();
tx.update(litters).set({ damId: null }).where(eq(litters.damId, id)).run();
tx.delete(rabbitEvents).where(eq(rabbitEvents.rabbitId, id)).run();
tx.delete(rabbits).where(eq(rabbits.id, id)).run();
});