Add pedigree lookup and ancestry tree page
New 🌳 Pedigree view: enter a dog's ISO chip or SKK registration number
and see its ancestry rendered as a tree. SKK has no public API, so the
server drives SKK Hunddata like a browser: it resolves the input to an
internal hundid via the Hund_sok.aspx/HundData page-method, renders 7
generations per pedigree page, parses the rowspan grid into ahnentafel
positions, and follows each generation's leaves deeper by reading their
hundid out of the __doPostBack response viewstate.
A lookup returns the first 7 generations immediately and crawls deeper in
the background; the client polls and fills the tree in as ancestors
arrive. Finished trees are cached per dog in a new pedigree_cache table
(pedigrees don't change), so a dog is crawled once and repeats are instant.
The endpoints sit behind auth like the rest of /api/*, and the crawl is
kept polite (warmed session, delay between requests, one coalesced job per
dog, hard caps).
This commit is contained in:
@@ -94,6 +94,22 @@ events, profile and photos.
|
||||
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
||||
aren't sent in the clear.
|
||||
|
||||
## Pedigree lookup
|
||||
|
||||
The 🌳 page looks up a dog in **SKK Hunddata** by ISO chip number or
|
||||
registration number and renders its ancestry as a tree.
|
||||
|
||||
- SKK has no public API, so the server drives the interactive site the way a
|
||||
browser would: it resolves the input to SKK's internal dog id, fetches the
|
||||
pedigree page (7 generations per request), and follows each generation's leaves
|
||||
deeper. A lookup returns the first generations immediately and keeps crawling in
|
||||
the background; the client polls and fills the tree in as ancestors arrive.
|
||||
- Because a deep crawl is dozens of sequential upstream requests, finished trees
|
||||
are cached per dog in the `pedigree_cache` table (pedigrees don't change), so a
|
||||
dog is only ever crawled once and repeat lookups are instant.
|
||||
- The lookup is behind auth like the rest of `/api/*`, and is **online-only** —
|
||||
it needs to reach SKK.
|
||||
|
||||
## Use it on NixOS
|
||||
|
||||
In your system flake:
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
pname = "puppy-tracker-server";
|
||||
version = "0.2.0";
|
||||
src = ./server;
|
||||
vendorHash = "sha256-z9Kf7i4WfLAHmceRi8T42+uMitjxEzr0pmOn+STpsAU=";
|
||||
vendorHash = "sha256-J1lYhwbaRh2PeAh3SzyB9WgUZa1gCNXBWdaJ5isUedA=";
|
||||
# Pure-Go build for a tiny static binary.
|
||||
env.CGO_ENABLED = "0";
|
||||
ldflags = [ "-s" "-w" ];
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
// Small helpers over golang.org/x/net/html for walking the SKK pedigree markup.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findByID returns the first element in the tree with the given id attribute.
|
||||
func findByID(n *html.Node, id string) *html.Node {
|
||||
if n.Type == html.ElementNode && attr(n, "id") == id {
|
||||
return n
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if got := findByID(c, id); got != nil {
|
||||
return got
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// descendants returns every element with the given tag anywhere under n, in
|
||||
// document order.
|
||||
func descendants(n *html.Node, tag string) []*html.Node {
|
||||
var out []*html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(x *html.Node) {
|
||||
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == tag {
|
||||
out = append(out, c)
|
||||
}
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return out
|
||||
}
|
||||
|
||||
// directChildElements returns the immediate element children of n with the tag.
|
||||
func directChildElements(n *html.Node, tag string) []*html.Node {
|
||||
var out []*html.Node
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == tag {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findElement returns the first descendant element with the given tag.
|
||||
func findElement(n *html.Node, tag string) *html.Node {
|
||||
els := descendants(n, tag)
|
||||
if len(els) == 0 {
|
||||
return nil
|
||||
}
|
||||
return els[0]
|
||||
}
|
||||
|
||||
// text concatenates all text under n.
|
||||
func text(n *html.Node) string {
|
||||
var sb strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(x *html.Node) {
|
||||
if x.Type == html.TextNode {
|
||||
sb.WriteString(x.Data)
|
||||
}
|
||||
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// normalizeText trims and collapses internal whitespace.
|
||||
func normalizeText(s string) string {
|
||||
return strings.TrimSpace(wsRE.ReplaceAllString(s, " "))
|
||||
}
|
||||
|
||||
// findBoldSpan returns the normalized text of the first bold <span> (how subject
|
||||
// cells carry the registration number), or "".
|
||||
func findBoldSpan(n *html.Node) string {
|
||||
for _, sp := range descendants(n, "span") {
|
||||
if strings.Contains(strings.ReplaceAll(attr(sp, "style"), " ", ""), "font-weight:bold") {
|
||||
if t := normalizeText(text(sp)); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -311,6 +311,13 @@ func openDB(path string) (*sql.DB, error) {
|
||||
user_id TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pedigree_cache (
|
||||
hundid TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
nodes TEXT NOT NULL DEFAULT '',
|
||||
generations INTEGER NOT NULL DEFAULT 0,
|
||||
fetched INTEGER NOT NULL DEFAULT 0
|
||||
);`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
@@ -600,6 +607,7 @@ func main() {
|
||||
store := newStore(db)
|
||||
configStore := newConfigStore(db)
|
||||
exerciseStore := newExerciseStore(db)
|
||||
pedigrees := newPedManager(db)
|
||||
|
||||
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
|
||||
if err := os.MkdirAll(photosDir, 0o755); err != nil {
|
||||
@@ -711,6 +719,24 @@ func main() {
|
||||
}
|
||||
}))
|
||||
|
||||
// POST /api/pedigree — resolve a dog by chip / registration number / name and
|
||||
// return its ancestry tree (immediately for the first generations, then a
|
||||
// background crawl deepens it). GET /api/pedigree/status polls that crawl.
|
||||
mux.HandleFunc("/api/pedigree", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pedigrees.handleLookup(w, r)
|
||||
}))
|
||||
mux.HandleFunc("/api/pedigree/status", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pedigrees.handleStatus(w, r)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,881 @@
|
||||
package main
|
||||
|
||||
// Pedigree lookup: resolve a dog by chip / registration number / name against
|
||||
// SKK (Svenska Kennelklubben) HUNDDATA, then crawl its ancestry and expose it as
|
||||
// an ahnentafel-positioned tree. SKK has no public API, so this scrapes the
|
||||
// interactive ASP.NET WebForms app the same way a browser drives it:
|
||||
//
|
||||
// 1. Resolve — POST Hund_sok.aspx/HundData (a JSON page-method) → hundid.
|
||||
// 2. Fetch — GET Hund_Stamtavla.aspx?hundid=X, then POST ddlGenerationer=7
|
||||
// to render 7 generations in one page; parse its rowspan grid.
|
||||
// 3. Deepen — each generation-7 leaf links via __doPostBack; POST that link
|
||||
// and read the ancestor's hundid out of the response __VIEWSTATE,
|
||||
// then recurse. BFS terminates when the ancestry runs out.
|
||||
//
|
||||
// A deep crawl is dozens of sequential requests, so a lookup returns the first
|
||||
// 7 generations immediately and keeps crawling in the background; the finished
|
||||
// tree is cached per hundid (pedigrees don't change) so a dog is crawled once.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
skkBase = "https://hundar.skk.se/hunddata/"
|
||||
skkUA = "Mozilla/5.0 (X11; Linux x86_64) puppy-tracker pedigree lookup"
|
||||
skkDelay = 250 * time.Millisecond // politeness between upstream requests
|
||||
crawlGens = 7 // generations SKK renders per page
|
||||
|
||||
maxCrawlPages = 400 // hard caps so a crawl can never run away
|
||||
maxCrawlRequests = 3000
|
||||
crawlDeadline = 5 * time.Minute
|
||||
maxActiveJobs = 4 // concurrent background crawls, total
|
||||
firstPageWait = 20 * time.Second
|
||||
)
|
||||
|
||||
// pedNode is one dog at an ahnentafel position (1 = subject, sire = 2n, dam = 2n+1).
|
||||
type pedNode struct {
|
||||
Reg string `json:"reg,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Titles string `json:"titles,omitempty"`
|
||||
Hundid string `json:"hundid,omitempty"`
|
||||
}
|
||||
|
||||
// pedSubject is the looked-up dog's headline info, from the resolver row.
|
||||
type pedSubject struct {
|
||||
Hundid string `json:"hundid"`
|
||||
Reg string `json:"reg"`
|
||||
Name string `json:"name"`
|
||||
Breed string `json:"breed"`
|
||||
Chip string `json:"chip"`
|
||||
Sex string `json:"sex,omitempty"`
|
||||
}
|
||||
|
||||
// skkClient is a single browser-like session against SKK (cookie jar + UA).
|
||||
type skkClient struct {
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newSKKClient() (*skkClient, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &skkClient{hc: &http.Client{Jar: jar, Timeout: 30 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c *skkClient) do(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", skkUA)
|
||||
return c.hc.Do(req)
|
||||
}
|
||||
|
||||
// warm establishes an ASP.NET session (SessionId + anti-XSRF cookies) that the
|
||||
// resolver and pedigree pages both require.
|
||||
func (c *skkClient) warm(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, skkBase+"Hund_sok.aspx", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// hundDataRow mirrors the fields the resolver page-method returns.
|
||||
type hundDataRow struct {
|
||||
Hundid string `json:"hundid"`
|
||||
Regnr string `json:"Regnr"`
|
||||
Hundnamn string `json:"hundnamn"`
|
||||
Chipnr string `json:"chipnr"`
|
||||
Rastext string `json:"rastext"`
|
||||
Kon string `json:"Kon"`
|
||||
IDnummer string `json:"IDnummer"`
|
||||
Antal string `json:"Antal"`
|
||||
IsError bool `json:"IsError"`
|
||||
ErrorText string `json:"ErrorText"`
|
||||
}
|
||||
|
||||
var digitsRE = regexp.MustCompile(`^\d+$`)
|
||||
|
||||
// resolve turns a user query (chip number, registration number, or name) into
|
||||
// matching dogs. The field is chosen by shape: a long all-digit string is a
|
||||
// chip; anything with a letter or slash is a registration number; otherwise a
|
||||
// name search (which may return several rows to disambiguate).
|
||||
func (c *skkClient) resolve(ctx context.Context, q string) ([]hundDataRow, error) {
|
||||
body := map[string]string{
|
||||
"txtRegnr": "", "txtIDnummer": "", "txtChipnr": "",
|
||||
"txtHundnamn": "", "ddlRasIn": "", "ddlKon": "", "txtLicensnr": "",
|
||||
}
|
||||
switch {
|
||||
case digitsRE.MatchString(q) && len(q) >= 10:
|
||||
body["txtChipnr"] = q
|
||||
case strings.ContainsAny(q, "/") || strings.IndexFunc(q, isLetter) >= 0:
|
||||
body["txtRegnr"] = q
|
||||
default:
|
||||
body["txtHundnamn"] = q
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
skkBase+"Hund_sok.aspx/HundData", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json;charset=utf-8")
|
||||
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("resolver HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var wrap struct {
|
||||
D []hundDataRow `json:"d"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("resolver response: %w", err)
|
||||
}
|
||||
// SKK signals "no matches" (and other soft failures like a query needing more
|
||||
// input) via a single IsError row rather than an HTTP error. Treat it as an
|
||||
// empty result so the caller reports a clean "not found" instead of a 502.
|
||||
if len(wrap.D) == 1 && wrap.D[0].IsError {
|
||||
return nil, nil
|
||||
}
|
||||
return wrap.D, nil
|
||||
}
|
||||
|
||||
func isLetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
// fetchPage GETs a dog's pedigree then POSTs ddlGenerationer=7 (with titles on)
|
||||
// to render 7 generations, returning that page's HTML. The returned HTML is used
|
||||
// for both grid parsing and the __doPostBack calls that resolve its leaf dogs,
|
||||
// so its hidden fields (viewstate / event validation) match its ctl ids.
|
||||
func (c *skkClient) fetchPage(ctx context.Context, hundid string) (string, string, error) {
|
||||
pageURL := skkBase + "Hund_Stamtavla.aspx?hundid=" + url.QueryEscape(hundid)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
first := string(raw)
|
||||
|
||||
form := hiddenFields(first)
|
||||
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
|
||||
form.Set("ctl00$bodyContent$ddlTitlar", "J")
|
||||
form.Set("__EVENTTARGET", "ctl00$bodyContent$ddlGenerationer")
|
||||
form.Set("__EVENTARGUMENT", "")
|
||||
html7, err := c.postForm(ctx, pageURL, form)
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
return html7, pageURL, nil
|
||||
}
|
||||
|
||||
func (c *skkClient) postForm(ctx context.Context, pageURL string, form url.Values) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pageURL,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", pageURL)
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
var (
|
||||
viewstateRE = regexp.MustCompile(`name="__VIEWSTATE" id="__VIEWSTATE" value="([^"]+)"`)
|
||||
// The clicked dog's internal hundid, encoded in the response viewstate as the
|
||||
// string "hundid", a type byte (\x05), a length byte, then ASCII digits.
|
||||
vsHundidRE = regexp.MustCompile(`(?s)hundid\x05.(\d+)`)
|
||||
)
|
||||
|
||||
// postbackHundid clicks an ancestor's __doPostBack link on the given page and
|
||||
// recovers that ancestor's hundid from the response viewstate. The rendered
|
||||
// pedigree table never re-roots on such a click, but the viewstate carries the
|
||||
// clicked dog's id — which is exactly the handle needed to fetch its own page.
|
||||
func (c *skkClient) postbackHundid(ctx context.Context, pageHTML, ctlid, pageURL string) (string, error) {
|
||||
form := hiddenFields(pageHTML)
|
||||
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
|
||||
form.Set("ctl00$bodyContent$ddlTitlar", "J")
|
||||
form.Set("__EVENTTARGET", ctlid)
|
||||
form.Set("__EVENTARGUMENT", "")
|
||||
resp, err := c.postForm(ctx, pageURL, form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := viewstateRE.FindStringSubmatch(resp)
|
||||
if m == nil {
|
||||
return "", nil
|
||||
}
|
||||
dec := decodeB64(m[1])
|
||||
mm := vsHundidRE.FindSubmatch(dec)
|
||||
if mm == nil {
|
||||
return "", nil
|
||||
}
|
||||
return string(mm[1]), nil
|
||||
}
|
||||
|
||||
func decodeB64(s string) []byte {
|
||||
if m := len(s) % 4; m != 0 {
|
||||
s += strings.Repeat("=", 4-m)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// hiddenFields collects every <input type=hidden> on a page into a form value
|
||||
// set, so an ASP.NET postback can echo back __VIEWSTATE / __EVENTVALIDATION etc.
|
||||
func hiddenFields(pageHTML string) url.Values {
|
||||
vals := url.Values{}
|
||||
node, err := html.Parse(strings.NewReader(pageHTML))
|
||||
if err != nil {
|
||||
return vals
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "input" {
|
||||
var typ, name, val string
|
||||
for _, a := range n.Attr {
|
||||
switch a.Key {
|
||||
case "type":
|
||||
typ = a.Val
|
||||
case "name":
|
||||
name = a.Val
|
||||
case "value":
|
||||
val = a.Val
|
||||
}
|
||||
}
|
||||
if typ == "hidden" && name != "" {
|
||||
vals.Set(name, val)
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return vals
|
||||
}
|
||||
|
||||
// gridCell is a parsed pedigree table cell.
|
||||
type gridCell struct {
|
||||
reg, name, titles, ctlid string
|
||||
occupied bool
|
||||
}
|
||||
|
||||
var doPostBackRE = regexp.MustCompile(`__doPostBack\('([^']+)'`)
|
||||
var wsRE = regexp.MustCompile(`\s+`)
|
||||
|
||||
// parseGrid reconstructs the rowspan-based pedigree table into columns. Column c
|
||||
// holds 2^c cells top-to-bottom; a cell's index within its column is its
|
||||
// ahnentafel offset. Returns column index -> ordered cells.
|
||||
func parseGrid(pageHTML string) map[int][]gridCell {
|
||||
node, err := html.Parse(strings.NewReader(pageHTML))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tbl := findByID(node, "bodyContent_tblStamtavla")
|
||||
if tbl == nil {
|
||||
return nil
|
||||
}
|
||||
occ := map[[2]int]bool{}
|
||||
type placed struct {
|
||||
r, c int
|
||||
cell gridCell
|
||||
}
|
||||
var placedCells []placed
|
||||
r := 0
|
||||
for _, tr := range descendants(tbl, "tr") {
|
||||
c := 0
|
||||
for _, td := range directChildElements(tr, "td") {
|
||||
for occ[[2]int{r, c}] {
|
||||
c++
|
||||
}
|
||||
rs := 1
|
||||
if v := attr(td, "rowspan"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
rs = n
|
||||
}
|
||||
}
|
||||
for dr := 0; dr < rs; dr++ {
|
||||
occ[[2]int{r + dr, c}] = true
|
||||
}
|
||||
placedCells = append(placedCells, placed{r, c, parseCell(td)})
|
||||
c++
|
||||
}
|
||||
r++
|
||||
}
|
||||
byCol := map[int][]placed{}
|
||||
for _, p := range placedCells {
|
||||
byCol[p.c] = append(byCol[p.c], p)
|
||||
}
|
||||
out := map[int][]gridCell{}
|
||||
for col, lst := range byCol {
|
||||
sort.SliceStable(lst, func(i, j int) bool { return lst[i].r < lst[j].r })
|
||||
cells := make([]gridCell, len(lst))
|
||||
for i, p := range lst {
|
||||
cells[i] = p.cell
|
||||
}
|
||||
out[col] = cells
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseCell extracts reg / name / titles / ctlid from one <td>, mirroring how
|
||||
// SKK marks them up: a <a __doPostBack> holds the reg number (subject cells use
|
||||
// a bold <span> instead), a <font> holds championship titles, and the last plain
|
||||
// <span> holds the dog's name. "Uppgift saknas" placeholders are left unoccupied.
|
||||
func parseCell(td *html.Node) gridCell {
|
||||
var cell gridCell
|
||||
if a := findElement(td, "a"); a != nil {
|
||||
if href := attr(a, "href"); strings.Contains(href, "__doPostBack") {
|
||||
cell.reg = normalizeText(text(a))
|
||||
if m := doPostBackRE.FindStringSubmatch(html.UnescapeString(href)); m != nil {
|
||||
cell.ctlid = m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
if f := findElement(td, "font"); f != nil {
|
||||
cell.titles = normalizeText(text(f))
|
||||
}
|
||||
// Name: the last <span> whose text isn't the titles string. Subject cells put
|
||||
// the reg in a leading bold span; if we found no link reg, adopt it.
|
||||
for _, sp := range descendants(td, "span") {
|
||||
t := normalizeText(text(sp))
|
||||
if t == "" || t == cell.titles {
|
||||
continue
|
||||
}
|
||||
cell.name = t
|
||||
}
|
||||
if cell.reg == "" {
|
||||
if b := findBoldSpan(td); b != "" {
|
||||
cell.reg = b
|
||||
if cell.name == b {
|
||||
cell.name = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(cell.name, "Uppgift saknas") {
|
||||
cell.name = ""
|
||||
}
|
||||
cell.occupied = cell.reg != "" || cell.name != ""
|
||||
return cell
|
||||
}
|
||||
|
||||
// crawlProgress is the mutable snapshot a running crawl publishes.
|
||||
type crawlProgress struct {
|
||||
Pages int
|
||||
Distinct int
|
||||
MaxGen int
|
||||
Nodes map[string]pedNode
|
||||
}
|
||||
|
||||
// crawl performs the breadth-first ancestry walk from a subject hundid, calling
|
||||
// report after each page with a fresh snapshot. It places every dog at its global
|
||||
// ahnentafel position and resolves each generation-7 leaf to a hundid to recurse.
|
||||
func (c *skkClient) crawl(ctx context.Context, hundid string, report func(crawlProgress)) (map[string]pedNode, error) {
|
||||
tree := map[string]pedNode{}
|
||||
edges := map[string]string{} // subjectHundid|ctlid -> ancestor hundid
|
||||
done := map[string]bool{}
|
||||
type qitem struct {
|
||||
hundid string
|
||||
basePos uint64
|
||||
}
|
||||
queue := []qitem{{hundid, 1}}
|
||||
pages, requests, maxGen := 0, 0, 0
|
||||
|
||||
snapshot := func() crawlProgress {
|
||||
nodes := make(map[string]pedNode, len(tree))
|
||||
for k, v := range tree {
|
||||
nodes[k] = v
|
||||
}
|
||||
return crawlProgress{Pages: pages, Distinct: countDistinct(tree), MaxGen: maxGen, Nodes: nodes}
|
||||
}
|
||||
|
||||
for len(queue) > 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return tree, err
|
||||
}
|
||||
if pages >= maxCrawlPages || requests >= maxCrawlRequests {
|
||||
log.Printf("pedigree crawl %s: hit cap (pages=%d requests=%d)", hundid, pages, requests)
|
||||
break
|
||||
}
|
||||
item := queue[0]
|
||||
queue = queue[1:]
|
||||
if done[item.hundid] {
|
||||
continue
|
||||
}
|
||||
done[item.hundid] = true
|
||||
|
||||
time.Sleep(skkDelay)
|
||||
pageHTML, pageURL, err := c.fetchPage(ctx, item.hundid)
|
||||
requests += 2
|
||||
if err != nil {
|
||||
if pages == 0 {
|
||||
return tree, err // couldn't even fetch the subject
|
||||
}
|
||||
log.Printf("pedigree crawl %s: fetch %s failed: %v", hundid, item.hundid, err)
|
||||
continue
|
||||
}
|
||||
pages++
|
||||
grid := parseGrid(pageHTML)
|
||||
|
||||
var frontier []struct {
|
||||
gpos uint64
|
||||
ctlid string
|
||||
}
|
||||
for col := 0; col <= crawlGens-1; col++ {
|
||||
cells, ok := grid[col]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for pos, cell := range cells {
|
||||
if !cell.occupied {
|
||||
continue
|
||||
}
|
||||
gpos := item.basePos*(1<<uint(col)) + uint64(pos)
|
||||
key := strconv.FormatUint(gpos, 10)
|
||||
node := tree[key]
|
||||
node.Reg, node.Name, node.Titles = cell.reg, cell.name, cell.titles
|
||||
if col == 0 {
|
||||
node.Hundid = item.hundid
|
||||
}
|
||||
tree[key] = node
|
||||
if g := bitsLen(gpos); g > maxGen {
|
||||
maxGen = g
|
||||
}
|
||||
if col == crawlGens-1 && cell.ctlid != "" {
|
||||
frontier = append(frontier, struct {
|
||||
gpos uint64
|
||||
ctlid string
|
||||
}{gpos, cell.ctlid})
|
||||
}
|
||||
}
|
||||
}
|
||||
report(snapshot())
|
||||
|
||||
for _, f := range frontier {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return tree, err
|
||||
}
|
||||
if requests >= maxCrawlRequests {
|
||||
break
|
||||
}
|
||||
ekey := item.hundid + "|" + f.ctlid
|
||||
hid, ok := edges[ekey]
|
||||
if !ok {
|
||||
time.Sleep(skkDelay)
|
||||
hid, err = c.postbackHundid(ctx, pageHTML, f.ctlid, pageURL)
|
||||
requests++
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
edges[ekey] = hid
|
||||
}
|
||||
if hid != "" {
|
||||
key := strconv.FormatUint(f.gpos, 10)
|
||||
node := tree[key]
|
||||
node.Hundid = hid
|
||||
tree[key] = node
|
||||
queue = append(queue, qitem{hid, f.gpos})
|
||||
}
|
||||
}
|
||||
}
|
||||
report(snapshot())
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func bitsLen(x uint64) int {
|
||||
n := 0
|
||||
for x > 0 {
|
||||
n++
|
||||
x >>= 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---- job manager ---------------------------------------------------------
|
||||
|
||||
type jobState string
|
||||
|
||||
const (
|
||||
jobRunning jobState = "running"
|
||||
jobDone jobState = "done"
|
||||
jobError jobState = "error"
|
||||
)
|
||||
|
||||
type pedJob struct {
|
||||
id string
|
||||
hundid string
|
||||
subject pedSubject
|
||||
firstPage chan struct{} // closed once the subject's own page is parsed
|
||||
|
||||
mu sync.Mutex
|
||||
state jobState
|
||||
pages int
|
||||
distinct int
|
||||
maxGen int
|
||||
nodes map[string]pedNode
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (j *pedJob) apply(p crawlProgress) {
|
||||
j.mu.Lock()
|
||||
j.pages, j.distinct, j.maxGen, j.nodes = p.Pages, p.Distinct, p.MaxGen, p.Nodes
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
type pedManager struct {
|
||||
db *sql.DB
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*pedJob // keyed by hundid (coalesces duplicate lookups)
|
||||
}
|
||||
|
||||
func newPedManager(db *sql.DB) *pedManager {
|
||||
return &pedManager{db: db, jobs: map[string]*pedJob{}}
|
||||
}
|
||||
|
||||
func (m *pedManager) activeCount() int {
|
||||
n := 0
|
||||
for _, j := range m.jobs {
|
||||
j.mu.Lock()
|
||||
if j.state == jobRunning {
|
||||
n++
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// startOrAttach returns the running/finished job for a hundid, or starts a new
|
||||
// background crawl. The boolean reports whether a fresh job was created.
|
||||
func (m *pedManager) startOrAttach(client *skkClient, subject pedSubject) (*pedJob, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if j, ok := m.jobs[subject.Hundid]; ok {
|
||||
return j, false, nil
|
||||
}
|
||||
if m.activeCount() >= maxActiveJobs {
|
||||
return nil, false, errors.New("busy: too many pedigree lookups in progress, try again shortly")
|
||||
}
|
||||
j := &pedJob{
|
||||
id: subject.Hundid,
|
||||
hundid: subject.Hundid,
|
||||
subject: subject,
|
||||
firstPage: make(chan struct{}),
|
||||
state: jobRunning,
|
||||
nodes: map[string]pedNode{},
|
||||
}
|
||||
m.jobs[subject.Hundid] = j
|
||||
go m.run(client, j)
|
||||
return j, true, nil
|
||||
}
|
||||
|
||||
func (m *pedManager) run(client *skkClient, j *pedJob) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), crawlDeadline)
|
||||
defer cancel()
|
||||
|
||||
firstDone := false
|
||||
report := func(p crawlProgress) {
|
||||
j.apply(p)
|
||||
if !firstDone && p.Pages >= 1 {
|
||||
firstDone = true
|
||||
close(j.firstPage)
|
||||
}
|
||||
}
|
||||
nodes, err := client.crawl(ctx, j.hundid, report)
|
||||
if !firstDone {
|
||||
close(j.firstPage) // unblock waiters even if the very first fetch failed
|
||||
}
|
||||
j.mu.Lock()
|
||||
if err != nil && len(nodes) == 0 {
|
||||
j.state = jobError
|
||||
j.errMsg = err.Error()
|
||||
} else {
|
||||
j.state = jobDone
|
||||
}
|
||||
j.mu.Unlock()
|
||||
|
||||
if len(nodes) > 0 {
|
||||
m.persist(j.subject, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot copies a job's current public state under its lock.
|
||||
func (j *pedJob) snapshot() (jobState, int, int, int, map[string]pedNode, string) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
nodes := make(map[string]pedNode, len(j.nodes))
|
||||
for k, v := range j.nodes {
|
||||
nodes[k] = v
|
||||
}
|
||||
return j.state, j.pages, j.distinct, j.maxGen, nodes, j.errMsg
|
||||
}
|
||||
|
||||
// ---- persistent cache ----------------------------------------------------
|
||||
|
||||
func (m *pedManager) persist(subject pedSubject, nodes map[string]pedNode) {
|
||||
sj, _ := json.Marshal(subject)
|
||||
nj, _ := json.Marshal(nodes)
|
||||
_, err := m.db.Exec(`
|
||||
INSERT INTO pedigree_cache (hundid, subject, nodes, generations, fetched)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hundid) DO UPDATE SET
|
||||
subject = excluded.subject, nodes = excluded.nodes,
|
||||
generations = excluded.generations, fetched = excluded.fetched`,
|
||||
subject.Hundid, string(sj), string(nj), maxGenerations(nodes), time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("pedigree cache save %s: %v", subject.Hundid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cached returns a stored tree for a hundid, if present.
|
||||
func (m *pedManager) cached(hundid string) (pedSubject, map[string]pedNode, bool) {
|
||||
var sj, nj string
|
||||
err := m.db.QueryRow(
|
||||
`SELECT subject, nodes FROM pedigree_cache WHERE hundid = ?`, hundid,
|
||||
).Scan(&sj, &nj)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("pedigree cache get %s: %v", hundid, err)
|
||||
}
|
||||
return pedSubject{}, nil, false
|
||||
}
|
||||
var subject pedSubject
|
||||
var nodes map[string]pedNode
|
||||
json.Unmarshal([]byte(sj), &subject)
|
||||
json.Unmarshal([]byte(nj), &nodes)
|
||||
return subject, nodes, true
|
||||
}
|
||||
|
||||
func maxGenerations(nodes map[string]pedNode) int {
|
||||
max := 0
|
||||
for k := range nodes {
|
||||
if p, err := strconv.ParseUint(k, 10, 64); err == nil {
|
||||
if g := bitsLen(p); g > max {
|
||||
max = g
|
||||
}
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ---- HTTP handlers -------------------------------------------------------
|
||||
|
||||
type pedLookupResponse struct {
|
||||
Status string `json:"status"` // done | running | choose
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
Hundid string `json:"hundid,omitempty"`
|
||||
Subject *pedSubject `json:"subject,omitempty"`
|
||||
Generations int `json:"generations,omitempty"`
|
||||
Nodes map[string]pedNode `json:"nodes,omitempty"`
|
||||
Matches []hundDataRow `json:"matches,omitempty"`
|
||||
}
|
||||
|
||||
func rowToSubject(r hundDataRow) pedSubject {
|
||||
return pedSubject{
|
||||
Hundid: r.Hundid,
|
||||
Reg: strings.TrimSpace(r.Regnr),
|
||||
Name: strings.TrimSpace(r.Hundnamn),
|
||||
Breed: strings.TrimSpace(r.Rastext),
|
||||
Chip: strings.TrimSpace(r.Chipnr),
|
||||
Sex: strings.TrimSpace(r.Kon),
|
||||
}
|
||||
}
|
||||
|
||||
// handleLookup resolves a query and returns a cached tree, a disambiguation list,
|
||||
// or an immediate 7-generation tree with a background job crawling deeper.
|
||||
func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Q string `json:"q"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(req.Q)
|
||||
if q == "" {
|
||||
http.Error(w, "empty query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client, err := newSKKClient()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := client.warm(ctx); err != nil {
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rows, err := client.resolve(ctx, q)
|
||||
if err != nil {
|
||||
http.Error(w, "lookup failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rows = withHundid(rows)
|
||||
switch {
|
||||
case len(rows) == 0:
|
||||
http.Error(w, "no dog found for "+q, http.StatusNotFound)
|
||||
return
|
||||
case len(rows) > 1:
|
||||
writeJSON(w, pedLookupResponse{Status: "choose", Matches: rows})
|
||||
return
|
||||
}
|
||||
subject := rowToSubject(rows[0])
|
||||
|
||||
if subj, nodes, ok := m.cached(subject.Hundid); ok {
|
||||
writeJSON(w, pedLookupResponse{
|
||||
Status: "done", Hundid: subj.Hundid, Subject: &subj,
|
||||
Generations: maxGenerations(nodes), Nodes: nodes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
job, _, err := m.startOrAttach(client, subject)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-job.firstPage:
|
||||
case <-time.After(firstPageWait):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
state, _, _, gen, nodes, msg := job.snapshot()
|
||||
if state == jobError {
|
||||
http.Error(w, "pedigree fetch failed: "+msg, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
status := "running"
|
||||
if state == jobDone {
|
||||
status = "done"
|
||||
}
|
||||
writeJSON(w, pedLookupResponse{
|
||||
Status: status, JobID: job.id, Hundid: subject.Hundid,
|
||||
Subject: &subject, Generations: gen, Nodes: nodes,
|
||||
})
|
||||
}
|
||||
|
||||
type pedStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Pages int `json:"pages"`
|
||||
Distinct int `json:"distinct"`
|
||||
Generations int `json:"generations"`
|
||||
Nodes map[string]pedNode `json:"nodes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleStatus returns a running crawl's current partial tree so the client can
|
||||
// fill the view in progressively, and the final tree when it finishes.
|
||||
func (m *pedManager) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("job")
|
||||
m.mu.Lock()
|
||||
job := m.jobs[id]
|
||||
m.mu.Unlock()
|
||||
if job == nil {
|
||||
// A finished job may have been evicted, but the cache still has the tree.
|
||||
if _, nodes, ok := m.cached(id); ok {
|
||||
writeJSON(w, pedStatusResponse{
|
||||
Status: "done", Generations: maxGenerations(nodes),
|
||||
Distinct: countDistinct(nodes), Nodes: nodes, Pages: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "unknown job", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state, pages, distinct, gen, nodes, msg := job.snapshot()
|
||||
writeJSON(w, pedStatusResponse{
|
||||
Status: string(state), Pages: pages, Distinct: distinct,
|
||||
Generations: gen, Nodes: nodes, Error: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// countDistinct counts unique ancestors, keyed by registration number (falling
|
||||
// back to name). Pedigree collapse means one dog can fill many positions, so
|
||||
// this is smaller than the number of occupied positions.
|
||||
func countDistinct(nodes map[string]pedNode) int {
|
||||
seen := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
k := n.Reg
|
||||
if k == "" {
|
||||
k = n.Name
|
||||
}
|
||||
if k != "" {
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// withHundid drops resolver rows lacking a usable hundid (defensive).
|
||||
func withHundid(rows []hundDataRow) []hundDataRow {
|
||||
out := rows[:0]
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.Hundid) != "" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+243
@@ -2415,6 +2415,249 @@
|
||||
settingsDialog.close();
|
||||
});
|
||||
|
||||
// ---------- pedigree lookup ----------
|
||||
// A separate full-screen view that resolves a dog against SKK Hunddata by
|
||||
// chip / registration number / name and renders its ancestry as a tree. The
|
||||
// server returns the first generations immediately and crawls deeper in the
|
||||
// background; we poll for that and re-render as ancestors arrive. Online-only.
|
||||
const pedScreen = document.getElementById("pedigree-screen");
|
||||
const pedForm = document.getElementById("pedigree-form");
|
||||
const pedQ = document.getElementById("pedigree-q");
|
||||
const pedStatus = document.getElementById("pedigree-status");
|
||||
const pedChoose = document.getElementById("pedigree-choose");
|
||||
const pedSubject = document.getElementById("pedigree-subject");
|
||||
const pedTree = document.getElementById("pedigree-tree");
|
||||
|
||||
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse
|
||||
let pedPollTimer = null;
|
||||
let pedNodes = {}; // latest ancestry map, for the progress count
|
||||
const pedQKey = () => `puppy-tracker:${currentUser.id}:pedigree-q:v1`;
|
||||
|
||||
function openPedigree() {
|
||||
appEl.hidden = true;
|
||||
pedScreen.hidden = false;
|
||||
if (!pedQ.value) {
|
||||
try { pedQ.value = localStorage.getItem(pedQKey()) || ""; } catch { /* ignore */ }
|
||||
}
|
||||
setTimeout(() => pedQ.focus(), 50);
|
||||
}
|
||||
function closePedigree() {
|
||||
stopPedPoll();
|
||||
pedScreen.hidden = true;
|
||||
appEl.hidden = false;
|
||||
}
|
||||
function stopPedPoll() {
|
||||
if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; }
|
||||
}
|
||||
|
||||
document.getElementById("pedigree-btn").addEventListener("click", openPedigree);
|
||||
document.getElementById("pedigree-back").addEventListener("click", closePedigree);
|
||||
|
||||
pedForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const q = pedQ.value.trim();
|
||||
if (!q) return;
|
||||
try { localStorage.setItem(pedQKey(), q); } catch { /* ignore */ }
|
||||
lookupPedigree(q);
|
||||
});
|
||||
|
||||
async function lookupPedigree(q) {
|
||||
stopPedPoll();
|
||||
pedChoose.hidden = true; pedChoose.textContent = "";
|
||||
pedSubject.hidden = true; pedSubject.textContent = "";
|
||||
pedTree.textContent = "";
|
||||
if (!navigator.onLine) {
|
||||
setPedStatus("Pedigree lookup needs an internet connection.", "error");
|
||||
return;
|
||||
}
|
||||
setPedStatus("Looking up…", "busy");
|
||||
let res;
|
||||
try {
|
||||
res = await fetch("api/pedigree", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ q }),
|
||||
});
|
||||
} catch {
|
||||
setPedStatus("Couldn't reach the server. Check your connection and try again.", "error");
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) { handleLoggedOut(); return; }
|
||||
if (res.status === 404) { setPedStatus(`No dog found for “${q}”.`, "error"); return; }
|
||||
if (!res.ok) {
|
||||
const msg = (await res.text().catch(() => "")).trim();
|
||||
setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.status === "choose") { renderChoose(data.matches || []); return; }
|
||||
renderSubject(data.subject);
|
||||
renderTree(data.nodes || {});
|
||||
if (data.status === "done") {
|
||||
setPedDone();
|
||||
} else {
|
||||
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
||||
pollPedigree(data.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
function pollPedigree(jobId) {
|
||||
stopPedPoll();
|
||||
const tick = async () => {
|
||||
let res;
|
||||
try { res = await fetch(`api/pedigree/status?job=${encodeURIComponent(jobId)}`); }
|
||||
catch { pedPollTimer = setTimeout(tick, 3000); return; }
|
||||
if (res.status === 401) { handleLoggedOut(); return; }
|
||||
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
|
||||
const data = await res.json();
|
||||
renderTree(data.nodes || {});
|
||||
if (data.status === "done") { setPedDone(); return; }
|
||||
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
|
||||
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
||||
pedPollTimer = setTimeout(tick, 1500);
|
||||
};
|
||||
pedPollTimer = setTimeout(tick, 1500);
|
||||
}
|
||||
|
||||
// Counts derived from the ancestry map we already hold, so the summary is
|
||||
// right whether it came from a fresh crawl, a poll, or a cache hit. Distinct
|
||||
// ancestors are keyed by registration number (pedigree collapse means one dog
|
||||
// fills many positions); "generations back" is the depth of the deepest
|
||||
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
|
||||
function pedCounts() {
|
||||
const seen = new Set();
|
||||
let maxPos = 1;
|
||||
for (const k in pedNodes) {
|
||||
const n = pedNodes[k];
|
||||
const key = n.reg || n.name;
|
||||
if (key) seen.add(key);
|
||||
const p = Number(k);
|
||||
if (p > maxPos) maxPos = p;
|
||||
}
|
||||
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) };
|
||||
}
|
||||
function pedCountText() {
|
||||
const { distinct: a, gens: g } = pedCounts();
|
||||
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
|
||||
}
|
||||
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
|
||||
function setPedStatus(text, kind) {
|
||||
pedStatus.hidden = false;
|
||||
pedStatus.textContent = text;
|
||||
pedStatus.className = "pedigree-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function renderSubject(s) {
|
||||
if (!s) { pedSubject.hidden = true; return; }
|
||||
pedSubject.hidden = false;
|
||||
pedSubject.textContent = "";
|
||||
const name = document.createElement("div");
|
||||
name.className = "ped-subject-name";
|
||||
name.textContent = s.name || "(unnamed)";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "ped-subject-meta";
|
||||
const bits = [];
|
||||
if (s.breed) bits.push(s.breed);
|
||||
if (s.reg) bits.push(s.reg);
|
||||
if (s.sex) bits.push(s.sex === "H" ? "♂" : s.sex === "T" ? "♀" : s.sex);
|
||||
meta.textContent = bits.join(" · ");
|
||||
pedSubject.append(name, meta);
|
||||
}
|
||||
|
||||
function renderChoose(matches) {
|
||||
pedTree.textContent = "";
|
||||
pedSubject.hidden = true;
|
||||
setPedStatus(`${matches.length} matches — pick one:`, "");
|
||||
pedChoose.hidden = false;
|
||||
pedChoose.textContent = "";
|
||||
matches.slice(0, 50).forEach((m) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "ped-match";
|
||||
const nm = (m.hundnamn || "").trim() || "(unnamed)";
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "ped-match-name";
|
||||
nameEl.textContent = nm;
|
||||
const metaEl = document.createElement("span");
|
||||
metaEl.className = "ped-match-meta";
|
||||
metaEl.textContent = [m.Regnr, m.rastext].filter(Boolean).join(" · ");
|
||||
b.append(nameEl, metaEl);
|
||||
b.addEventListener("click", () => {
|
||||
pedChoose.hidden = true;
|
||||
const q = (m.Regnr || "").trim() || nm;
|
||||
pedQ.value = q;
|
||||
try { localStorage.setItem(pedQKey(), q); } catch { /* ignore */ }
|
||||
lookupPedigree(q);
|
||||
});
|
||||
pedChoose.append(b);
|
||||
});
|
||||
}
|
||||
|
||||
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
|
||||
// 2n+1. We build recursively (sire above dam) and collapse below PED_OPEN_DEPTH.
|
||||
function renderTree(nodes) {
|
||||
pedNodes = nodes;
|
||||
pedTree.textContent = "";
|
||||
const root = buildPedNode(nodes, 1, 0);
|
||||
if (root) pedTree.append(root);
|
||||
}
|
||||
|
||||
function buildPedNode(nodes, pos, depth) {
|
||||
const n = nodes[String(pos)];
|
||||
const hasSire = !!nodes[String(pos * 2)];
|
||||
const hasDam = !!nodes[String(pos * 2 + 1)];
|
||||
if (!n && !hasSire && !hasDam) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ped-node";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "ped-card";
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "ped-name";
|
||||
nameEl.textContent = n ? (n.name || "(unnamed)") : "Unknown";
|
||||
if (!n) nameEl.classList.add("ped-unknown");
|
||||
card.append(nameEl);
|
||||
if (n && n.titles) {
|
||||
const t = document.createElement("div");
|
||||
t.className = "ped-titles";
|
||||
t.textContent = n.titles;
|
||||
card.append(t);
|
||||
}
|
||||
if (n && n.reg) {
|
||||
const r = document.createElement("div");
|
||||
r.className = "ped-reg";
|
||||
r.textContent = n.reg;
|
||||
card.append(r);
|
||||
}
|
||||
|
||||
if (hasSire || hasDam) {
|
||||
const kids = document.createElement("div");
|
||||
kids.className = "ped-children";
|
||||
const s = buildPedNode(nodes, pos * 2, depth + 1);
|
||||
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
|
||||
if (s) { s.classList.add("ped-sire"); kids.append(s); }
|
||||
if (d) { d.classList.add("ped-dam"); kids.append(d); }
|
||||
|
||||
const collapsed = depth >= PED_OPEN_DEPTH;
|
||||
if (collapsed) wrap.classList.add("collapsed");
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "ped-toggle";
|
||||
toggle.setAttribute("aria-label", "Toggle ancestors");
|
||||
toggle.textContent = collapsed ? "+" : "−";
|
||||
toggle.addEventListener("click", () => {
|
||||
const nowCollapsed = wrap.classList.toggle("collapsed");
|
||||
toggle.textContent = nowCollapsed ? "+" : "−";
|
||||
});
|
||||
card.prepend(toggle);
|
||||
wrap.append(card, kids);
|
||||
} else {
|
||||
wrap.append(card);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---------- changelog dialog ----------
|
||||
// Shows the *loaded* build's full changelog: the plain URL is served
|
||||
// cache-first by the controlling service worker, so the list always matches
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: look up any dog in SKK Hunddata by chip or registration number and explore its ancestry as a tree — the first generations show at once, then the line fills in further back as it's traced" },
|
||||
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
|
||||
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend y-axis is stretched above 10h, giving the hours around the sleep goal most of the chart" },
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<div id="puppy-age" class="puppy-age" hidden></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" id="pedigree-btn" class="ghost icon-btn" aria-label="Pedigree" title="Pedigree">🌳</button>
|
||||
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
|
||||
<button type="button" id="logout-btn" class="ghost icon-btn" aria-label="Log out" title="Log out">🚪</button>
|
||||
<div id="online-status" class="status-pill"></div>
|
||||
@@ -272,6 +273,32 @@
|
||||
</footer>
|
||||
</div><!-- /#app -->
|
||||
|
||||
<!-- Pedigree lookup. A distinct full-screen view (hides #app while open)
|
||||
that resolves a dog by chip / registration number / name against SKK
|
||||
and renders its ancestry as a tree. Online-only. -->
|
||||
<div id="pedigree-screen" class="pedigree-screen" hidden>
|
||||
<header class="pedigree-header">
|
||||
<button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back">←</button>
|
||||
<h1>🌳 Pedigree</h1>
|
||||
</header>
|
||||
<main class="pedigree-main">
|
||||
<form id="pedigree-form" class="pedigree-form">
|
||||
<input type="text" id="pedigree-q" autocomplete="off" spellcheck="false"
|
||||
placeholder="Chip or registration number" aria-label="Dog chip or registration number" />
|
||||
<button type="submit" id="pedigree-lookup">Look up</button>
|
||||
</form>
|
||||
<p class="muted-note pedigree-hint">
|
||||
Looks up a dog in <strong>SKK Hunddata</strong> by ISO chip number
|
||||
(e.g. 752095600044144) or registration number (e.g. SE23536/2026),
|
||||
then traces its ancestry. Needs an internet connection.
|
||||
</p>
|
||||
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
||||
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
||||
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
|
||||
<div id="pedigree-tree" class="pedigree-tree"></div>
|
||||
</main>
|
||||
</div><!-- /#pedigree-screen -->
|
||||
|
||||
<dialog id="changelog-dialog">
|
||||
<form method="dialog" id="changelog-form">
|
||||
<h3>Changelog</h3>
|
||||
|
||||
+158
@@ -1053,3 +1053,161 @@ section.collapsible > h2::after {
|
||||
section.collapsed > h2::after { transform: translateY(-50%) rotate(-90deg); }
|
||||
section.collapsed > h2 { margin-bottom: 0; }
|
||||
section.collapsed > :not(h2) { display: none; }
|
||||
|
||||
/* ---------- pedigree lookup ---------- */
|
||||
.pedigree-screen {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.pedigree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 4px 8px;
|
||||
}
|
||||
.pedigree-header h1 { font-size: 1.4rem; }
|
||||
|
||||
.pedigree-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.pedigree-form input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.pedigree-form button {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 16px;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pedigree-hint { margin: 4px 0 12px; }
|
||||
|
||||
.pedigree-status {
|
||||
margin: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pedigree-status.busy::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 12px; height: 12px;
|
||||
margin-right: 8px;
|
||||
vertical-align: -1px;
|
||||
border: 2px solid var(--accent);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ped-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes ped-spin { to { transform: rotate(360deg); } }
|
||||
.pedigree-status.error { color: var(--danger); }
|
||||
.pedigree-status.done { color: var(--gain); }
|
||||
|
||||
/* disambiguation list */
|
||||
.pedigree-choose {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.ped-match {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ped-match:hover { border-color: var(--accent); }
|
||||
.ped-match-name { font-weight: 600; }
|
||||
.ped-match-meta { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
/* looked-up dog */
|
||||
.pedigree-subject {
|
||||
margin: 6px 0 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
|
||||
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* the tree */
|
||||
.pedigree-tree {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.ped-node { position: relative; }
|
||||
.ped-card {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 6px 24px 6px 28px;
|
||||
margin: 3px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.ped-name { font-weight: 600; font-size: 0.95rem; }
|
||||
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
|
||||
.ped-titles { font-size: 0.72rem; color: var(--accent); margin-top: 1px; }
|
||||
.ped-reg {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* lineage spine: children indented under the card */
|
||||
.ped-children {
|
||||
margin-left: 16px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.ped-node.collapsed > .ped-children { display: none; }
|
||||
|
||||
/* sire ♂ (blue) above dam ♀ (purple) markers on the parent-role cards */
|
||||
.ped-sire > .ped-card { border-left: 3px solid var(--sleep); }
|
||||
.ped-dam > .ped-card { border-left: 3px solid var(--training); }
|
||||
.ped-sire > .ped-card::after,
|
||||
.ped-dam > .ped-card::after {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
top: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
|
||||
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
|
||||
|
||||
/* expand/collapse toggle */
|
||||
.ped-toggle {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px; height: 18px;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ped-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
Reference in New Issue
Block a user