"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95). The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a synced band term. We map thickness_in into the first half-open band [low, high) that contains it, derived only from the live synced term names/slugs — never a hardcoded Woo term id. Round-up semantics: bands are half-open on the upper bound, so a value that sits on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that value (1"–1 1/2"), not the band that ends there (3/4"–1"). """ from __future__ import annotations import re from typing import Any, Iterable, Optional # Mixed number / fraction / decimal inch token, optional trailing quote. _INCH_RE = re.compile( r"(?P\d+)\s+(?P\d+)\s*/\s*(?P\d+)" # 1 1/2 r"|(?P\d+)\s*/\s*(?P\d+)" # 1/2 r"|(?P\d+(?:\.\d+)?)", # 1 or 1.5 re.IGNORECASE, ) _QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE) _QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE) def parse_inch_token(text: str) -> Optional[float]: """Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches.""" t = (text or "").strip().rstrip("\"'").strip() if not t: return None m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t) if not m: return None if m.group("a") is not None: return float(m.group("a")) + float(m.group("n")) / float(m.group("d")) if m.group("n2") is not None: return float(m.group("n2")) / float(m.group("d2")) if m.group("dec") is not None: return float(m.group("dec")) return None def _bounds_from_name(name: str) -> Optional[tuple[float, float]]: """Return half-open [low, high) inches from a Woo thickness term name.""" raw = (name or "").strip() low_s = raw.lower() if not raw: return None # Under X / less than X / < X m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE) if m: hi = parse_inch_token(m.group(1)) if hi is not None: return (0.0, hi) # Over X / above X / > X / X+ m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE) if m: lo = parse_inch_token(m.group(1)) if lo is not None: return (lo, float("inf")) m = re.match(r"^(.+?)\+\s*$", raw) if m: lo = parse_inch_token(m.group(1)) if lo is not None: return (lo, float("inf")) # Range: "A - B" / "A–B" / "A- B" (quotes optional on either side) parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1) if len(parts) == 2: lo = parse_inch_token(parts[0]) hi = parse_inch_token(parts[1]) if lo is not None and hi is not None and hi > lo: return (lo, hi) # Single value band treated as [v, v+epsilon) — rare only = parse_inch_token(raw) if only is not None: return (only, only + 1e-9) return None def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]: """Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'.""" s = (slug or "").strip().lower() if not s: return None if s in {"xthin", "under", "thin"}: return (0.0, 0.5) m = _QTR_RANGE_RE.match(s) if m: lo = int(m.group(1)) * 0.25 hi = int(m.group(2)) * 0.25 if hi > lo: return (lo, hi) m = _QTR_MIN_RE.match(s) if m: lo = int(m.group(1)) * 0.25 return (lo, float("inf")) return None def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]: """Bounds for one synced thickness term; name first, slug fallback.""" return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug( str(term.get("slug") or "") ) def resolve_thickness_band_id( thickness_in: float | int | None, terms: Iterable[dict[str, Any]], ) -> Optional[int]: """Pick the synced thickness term id for thickness_in, or None if unknown. terms: iterable of {id, name, slug?} from the live Woo thickness attribute. """ if thickness_in is None: return None try: t = float(thickness_in) except (TypeError, ValueError): return None if t < 0: return None scored: list[tuple[float, float, int]] = [] # (low, high, id) for term in terms: b = term_bounds(term) if b is None: continue tid = term.get("id") if tid is None: continue lo, hi = b scored.append((lo, hi, int(tid))) if not scored: return None # Prefer the tightest band that contains t under half-open [low, high). matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi] if matches: matches.sort() # tightest span first return matches[0][3] # If nothing matched (gaps in taxonomy), pick nearest band by lower edge. scored.sort(key=lambda x: x[0]) for lo, hi, tid in scored: if t < hi: return tid return scored[-1][2] def load_thickness_terms(conn) -> list[dict[str, Any]]: """Live Thickness attribute terms from the synced Woo cache.""" row = conn.execute( "SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?", ("thickness", "thickness"), ).fetchone() if row is None: # Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open) row = conn.execute( "SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'" ).fetchone() if row is None: return [] attr_id = row[0] rows = conn.execute( "SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id", (attr_id,), ).fetchall() return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]