Structural fix (god, Julian-reported via 'C'): the loader ignored
SourceAnimal.ParentRefs, so animals whose ancestry exists only as
Stammbaum chart-position refs loaded with LitterId=null ("unbekannt").
ImportService now synthesizes/reuses a derived litter from parentRefs:
resolves father+mother via name+DOB, groups siblings (same parents+dob)
into one litter, sets Father/Mother + offspring LitterId, dates it to the
offspring DOB, and tags Notes "aus Stammbaum-Diagramm abgeleitet
(Konfidenz: …)" so it's transparent/reversible. Existing animals that
become linkable are re-linked on re-run (sweep-idempotent). Dry-run counts
included. Projected: ~124 loadable animals gain a parent link.
Box-colour = sex (Julian): blue box = male, white box = female. All 11
pedigrees encode this as a solid theme-8 (accent5/blue) fill vs no fill.
xlsx_util.cell_fill_sex reads it; extract.py sets animal.gender from the
box; ImportService.InferGender prefers it over sire/dam name inference.
Result: 306/306 loadable animals now sexed (154♂/152♀).
Extractor noise fix (god): reject Farbschlag values that are actually a
parent NAME bled across cells (contain v.d./von/of/gen.) — cleared phantom
conflicts (e.g. Chayton). Combined with GEN-3 Uw→G: Konflikte 32→21.
Also skip Excel "~$" lock files in the glob.
GEN-3a contract (Kevin): ComposeGenotype appends "Slsl" for WP/Sls
carriers (wild-type sl/sl omitted) so 8-locus strings stay unchanged.
Importer-only. The live re-import into Julian's DB stays a separate
supervised gated step. 95 C# tests + python genotype tests green;
has-pending-model-changes clean (no schema change on this branch).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
6.1 KiB
Python
176 lines
6.1 KiB
Python
"""Minimal dependency-free .xlsx reader (xlsx = zip of XML).
|
|
|
|
We only need: shared strings, cell text by reference, and image/drawing anchors.
|
|
Using stdlib zipfile + regex keeps this migration tooling free of openpyxl so it
|
|
runs anywhere Python 3 is present.
|
|
"""
|
|
import zipfile
|
|
import re
|
|
import html
|
|
import os
|
|
|
|
_T = re.compile(r"<t[^>]*>(.*?)</t>", re.S)
|
|
_SI = re.compile(r"<si>(.*?)</si>", re.S)
|
|
_CELL = re.compile(
|
|
r'<c\s+([^>]*?)>(?:<f[^>]*>.*?</f>)?(?:<v>(.*?)</v>|<is>(.*?)</is>)?</c>', re.S)
|
|
_ATTR = re.compile(r'(\w+)="([^"]*)"')
|
|
|
|
|
|
def col_to_num(col):
|
|
n = 0
|
|
for ch in col:
|
|
n = n * 26 + (ord(ch) - 64)
|
|
return n
|
|
|
|
|
|
def num_to_col(n):
|
|
s = ""
|
|
while n > 0:
|
|
n, r = divmod(n - 1, 26)
|
|
s = chr(65 + r) + s
|
|
return s
|
|
|
|
|
|
def shared_strings(z):
|
|
try:
|
|
raw = z.read("xl/sharedStrings.xml").decode("utf-8")
|
|
except KeyError:
|
|
return []
|
|
return [html.unescape("".join(_T.findall(si))).strip() for si in _SI.findall(raw)]
|
|
|
|
|
|
def sheet_paths(z):
|
|
"""Return worksheet xml paths in workbook order (best effort)."""
|
|
paths = sorted(n for n in z.namelist()
|
|
if re.match(r"xl/worksheets/sheet\d+\.xml$", n))
|
|
return paths
|
|
|
|
|
|
def read_cells(z, sheet_path, ss=None):
|
|
"""Return {(colnum, row): text} for a worksheet."""
|
|
if ss is None:
|
|
ss = shared_strings(z)
|
|
raw = z.read(sheet_path).decode("utf-8")
|
|
cells = {}
|
|
for attrs, v, istr in _CELL.findall(raw):
|
|
a = dict(_ATTR.findall(attrs))
|
|
ref = a.get("r")
|
|
if not ref:
|
|
continue
|
|
m = re.match(r"([A-Z]+)(\d+)", ref)
|
|
if not m:
|
|
continue
|
|
col, row = m.group(1), int(m.group(2))
|
|
typ = a.get("t")
|
|
if typ == "s" and v != "":
|
|
try:
|
|
text = ss[int(v)]
|
|
except (ValueError, IndexError):
|
|
text = ""
|
|
elif typ == "inlineStr" and istr:
|
|
text = html.unescape("".join(_T.findall(istr)))
|
|
elif v != "":
|
|
text = html.unescape(v)
|
|
else:
|
|
continue
|
|
if text.strip():
|
|
cells[(col_to_num(col), row)] = text.strip()
|
|
return cells
|
|
|
|
|
|
def cell_fill_sex(z, sheet_path):
|
|
"""{(colnum, row): 'male' | 'female'} from the cell's box fill colour.
|
|
|
|
Breeder convention (Julian, 2026-06-06): a BLUE box = männlich (male), a WHITE box =
|
|
weiblich (female). In these Stammbaum templates every coloured box uses one solid theme
|
|
fill (Office accent5 = blue); unfilled/none cells render white. So: a cell whose style
|
|
uses a real solid fill -> 'male'; an unfilled (none/gray125) cell -> 'female'.
|
|
"""
|
|
try:
|
|
st = z.read("xl/styles.xml").decode("utf-8")
|
|
except KeyError:
|
|
return {}
|
|
fills = re.search(r"<fills.*?</fills>", st, re.S)
|
|
colored = set()
|
|
if fills:
|
|
for i, fb in enumerate(re.findall(r"<fill>(.*?)</fill>", fills.group(0), re.S)):
|
|
if 'patternType="solid"' in fb and re.search(r"<fgColor\s", fb) and "gray125" not in fb:
|
|
colored.add(i) # fillId of a real solid colour (blue)
|
|
xfs = re.search(r"<cellXfs.*?</cellXfs>", st, re.S)
|
|
idx2fill = {}
|
|
if xfs:
|
|
for i, xf in enumerate(re.findall(r"<xf\b([^>]*?)/?>", xfs.group(0))):
|
|
m = re.search(r'fillId="(\d+)"', xf)
|
|
idx2fill[i] = int(m.group(1)) if m else 0
|
|
raw = z.read(sheet_path).decode("utf-8")
|
|
out = {}
|
|
for m in re.finditer(r"<c\s+([^>]*?)>", raw):
|
|
a = dict(_ATTR.findall(m.group(1)))
|
|
ref = a.get("r")
|
|
if not ref:
|
|
continue
|
|
mm = re.match(r"([A-Z]+)(\d+)", ref)
|
|
if not mm:
|
|
continue
|
|
s = int(a.get("s", "0"))
|
|
out[(col_to_num(mm.group(1)), int(mm.group(2)))] = (
|
|
"male" if idx2fill.get(s, 0) in colored else "female")
|
|
return out
|
|
|
|
|
|
def header_row(cells):
|
|
"""Return {colnum: header_text} for the topmost row that has text."""
|
|
if not cells:
|
|
return {}
|
|
top = min(r for (_, r) in cells)
|
|
return {c: t for (c, r), t in cells.items() if r == top}
|
|
|
|
|
|
def image_anchors(z):
|
|
"""Yield (sheet_path, from_col, from_row, media_zip_path) for every image anchor.
|
|
|
|
drawing rels map rId -> media target; the worksheet rels map the drawing to a
|
|
sheet. We resolve sheet via worksheet _rels where possible, else attribute all
|
|
anchors to the single worksheet (these files are single-sheet charts).
|
|
"""
|
|
out = []
|
|
# sheet -> drawing
|
|
sheet_drawing = {}
|
|
for sp in sheet_paths(z):
|
|
rels = "xl/worksheets/_rels/" + os.path.basename(sp) + ".rels"
|
|
try:
|
|
r = z.read(rels).decode("utf-8")
|
|
except KeyError:
|
|
continue
|
|
for rid, tgt in re.findall(r'Id="([^"]+)"[^>]*Target="([^"]+)"', r):
|
|
if "drawing" in tgt:
|
|
dpath = os.path.normpath(os.path.join(
|
|
"xl/worksheets", tgt)).replace("\\", "/")
|
|
sheet_drawing[sp] = dpath
|
|
for sp, dpath in sheet_drawing.items():
|
|
try:
|
|
d = z.read(dpath).decode("utf-8")
|
|
except KeyError:
|
|
continue
|
|
drels = os.path.join(os.path.dirname(dpath), "_rels",
|
|
os.path.basename(dpath) + ".rels").replace("\\", "/")
|
|
relmap = {}
|
|
try:
|
|
rr = z.read(drels).decode("utf-8")
|
|
for rid, tgt in re.findall(r'Id="([^"]+)"[^>]*Target="([^"]+)"', rr):
|
|
relmap[rid] = os.path.normpath(os.path.join(
|
|
os.path.dirname(dpath), tgt)).replace("\\", "/")
|
|
except KeyError:
|
|
pass
|
|
for anc in re.findall(r"<xdr:(?:two|one)CellAnchor.*?</xdr:(?:two|one)CellAnchor>", d, re.S):
|
|
fm = re.search(
|
|
r"<xdr:from>.*?<xdr:col>(\d+)</xdr:col>.*?<xdr:row>(\d+)</xdr:row>", anc, re.S)
|
|
emb = re.search(r'r:embed="([^"]+)"', anc)
|
|
if fm and emb:
|
|
media = relmap.get(emb.group(1))
|
|
if media:
|
|
# xdr col/row are 0-based; convert to 1-based to match cell refs
|
|
out.append((sp, int(fm.group(1)) + 1,
|
|
int(fm.group(2)) + 1, media))
|
|
return out
|