← back to Designer Wallcoverings
Double-roll S/R+D/R lengths: new sqft-classified script (single vs bolt per product) + theme renders explicit single/double_roll_length rows (fixes Thibaut halving mismatch); Thibaut live 2198 mf
7f189ae9b78da12799906142e556d45014e1184c · 2026-07-08 10:47:13 -0700 · steve@designerwallcoverings.com
Files touched
A shopify/scripts/set-doubleroll-lengths.pyM shopify/theme-LIVE-591/snippets/product-description-meta.liquid
Diff
commit 7f189ae9b78da12799906142e556d45014e1184c
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Wed Jul 8 10:47:13 2026 -0700
Double-roll S/R+D/R lengths: new sqft-classified script (single vs bolt per product) + theme renders explicit single/double_roll_length rows (fixes Thibaut halving mismatch); Thibaut live 2198 mf
---
shopify/scripts/set-doubleroll-lengths.py | 273 +++++++++++++++++++++
.../snippets/product-description-meta.liquid | 12 +-
2 files changed, 284 insertions(+), 1 deletion(-)
diff --git a/shopify/scripts/set-doubleroll-lengths.py b/shopify/scripts/set-doubleroll-lengths.py
new file mode 100644
index 00000000..c7054d44
--- /dev/null
+++ b/shopify/scripts/set-doubleroll-lengths.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+Double-roll S/R + D/R LENGTH standardization for the 4 double-roll PL families
+(Steve 2026-07-08: "all needs to be 2 and 2 — most brewster, york, wallquest, thibaut").
+
+RULE ("2 and 2"): every roll product shows BOTH a single-roll length (S/R) and a
+double-roll length (D/R) where D/R = 2 x S/R, and is ordered min 2 / step 2 (2 single
+rolls = 1 double roll). This writes EXPLICIT metafields so the PDP renders S/R and D/R
+directly instead of the theme guessing by halving global.length (the guess is right for
+American bolts, wrong for Thibaut singles — that mismatch is exactly what this fixes).
+
+Per-vendor the stored catalog length means DIFFERENT things, so we DETECT it per product
+via a width x length sq-ft check instead of hardcoding:
+ ~22-42 sq ft -> the value IS the single roll -> single = value, double = 2 x value
+ ~48-78 sq ft -> the value IS the double/bolt -> double = value, single = value / 2
+ else -> ODD (mural/panel/short-cut/unknown)-> min/step only, length UNTOUCHED, flagged
+
+Metafields written (global namespace, single_line_text_field):
+ single_roll_length = S/R length (units preserved)
+ double_roll_length = D/R length = 2 x S/R (units preserved)
+ v_prods_quantity_order_min/units = "2" (product) + custom.v_prod_quantity_order_min/units on Roll variants
+ unit_of_measure = composite copy: "...Single roll: W x S/R. Double roll: W x D/R."
+NOTE: global.length is NOT mutated here (other consumers read it). The theme change reads
+the explicit single_roll_length/double_roll_length when present.
+
+Length source per product: the vendor catalog (PostgreSQL, source of truth) by dw_sku,
+falling back to the live global.length. Vendor is selected by EMITTED Shopify vendor name.
+
+Vendors (--vendor):
+ thibaut -> Shopify vendor 'Thibaut' catalog thibaut_catalog.roll_length (mostly SINGLE)
+ wallquest -> Shopify vendor 'Malibu Wallpaper' catalog wallquest_catalog.length (mostly BOLT)
+ brewster -> Shopify vendor 'Malibu Walls' catalog brewster_catalog.roll_length (mostly BOLT)
+ york -> Shopify vendor 'York' catalog york_catalog.roll_length (mostly BOLT)
+
+Default: --dry (print only + classification tally + sample). LIVE writes require --live.
+Idempotent (writes only metafields whose value differs). Before-state -> audits/ for rollback.
+Run: SHOPIFY_ADMIN_TOKEN=... python3 set-doubleroll-lengths.py --vendor thibaut [--live]
+"""
+import json, os, re, sys, time, subprocess, urllib.request, urllib.error
+
+TOKEN = os.environ["SHOPIFY_ADMIN_TOKEN"]
+SHOP = os.environ.get("SHOPIFY_SHOP", "designer-laboratory-sandbox.myshopify.com")
+GQL = f"https://{SHOP}/admin/api/2024-10/graphql.json"
+PGURI = "postgresql:///dw_unified?host=/tmp"
+LIVE = "--live" in sys.argv
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+def arg(flag, default=None):
+ return sys.argv[sys.argv.index(flag)+1] if flag in sys.argv and sys.argv.index(flag)+1 < len(sys.argv) else default
+
+VENDORS = {
+ "thibaut": {"shopify_vendor": "Thibaut", "table": "thibaut_catalog", "len_col": "roll_length"},
+ "wallquest": {"shopify_vendor": "Malibu Wallpaper", "table": "wallquest_catalog", "len_col": "length"},
+ "brewster": {"shopify_vendor": "Malibu Walls", "table": "brewster_catalog", "len_col": "roll_length"},
+ "york": {"shopify_vendor": "York", "table": "york_catalog", "len_col": "roll_length"},
+}
+VKEY = arg("--vendor")
+if VKEY not in VENDORS:
+ raise SystemExit(f"--vendor must be one of {list(VENDORS)}")
+CFG = VENDORS[VKEY]
+BEFORE = os.path.join(HERE, "audits", f"doubleroll-lengths-{VKEY}-before.jsonl")
+os.makedirs(os.path.dirname(BEFORE), exist_ok=True)
+
+# single ~28-32 sqft, double ~56-64; windows chosen wide enough for real variance,
+# narrow enough that a mural (14 sqft) or a jumbo panel falls in the ODD gap.
+SINGLE_LO, SINGLE_HI = 22, 42
+DOUBLE_LO, DOUBLE_HI = 48, 78
+
+EXCLUDE_RE = re.compile(r"sold per\s*\(|per yard|yard|sample|memo|mural|panel", re.I)
+ROLL_RE = re.compile(r"\broll\b", re.I)
+
+def gql(q, v=None):
+ body = json.dumps({"query": q, "variables": v or {}}).encode()
+ req = urllib.request.Request(GQL, data=body, headers={
+ "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+ for _ in range(8):
+ try:
+ with urllib.request.urlopen(req) as r:
+ d = json.load(r)
+ if d.get("errors"):
+ s = json.dumps(d["errors"])
+ if "THROTTLED" in s: time.sleep(2.5); continue
+ raise SystemExit("GQL ERR: " + s)
+ return d["data"]
+ except urllib.error.HTTPError as e:
+ if e.code == 429: time.sleep(2.5); continue
+ raise
+ raise SystemExit("retries exhausted")
+
+def psql(sql):
+ return subprocess.run(["psql", PGURI, "-tA", "-F", "\t", "-c", sql],
+ capture_output=True, text=True, check=True).stdout
+
+def load_catalog():
+ """dw_sku -> (width, length) from the vendor catalog (PG source of truth)."""
+ lc = CFG["len_col"]
+ out = psql(f"SELECT dw_sku, COALESCE(width,''), COALESCE({lc},'') FROM {CFG['table']} "
+ f"WHERE COALESCE(dw_sku,'')<>''")
+ m = {}
+ for line in out.splitlines():
+ p = line.split("\t")
+ if len(p) == 3 and p[0]:
+ m[p[0].strip().upper()] = (p[1].strip(), p[2].strip())
+ return m
+
+def width_in(s):
+ if not s: return None
+ m = re.search(r"(\d+(?:\.\d+)?)", str(s))
+ return float(m.group(1)) if m else None
+
+def length_ft(s):
+ """primary numeric of a length string -> feet (for the sqft classifier)."""
+ if not s: return None
+ m = re.search(r"(\d+(?:\.\d+)?)\s*(yd|yard|yards|ft|feet|foot|in|inch|inches|m|cm|meter|metre)?",
+ str(s), re.I)
+ if not m: return None
+ val = float(m.group(1)); u = (m.group(2) or "").lower()
+ if u in ("yd","yard","yards"): return val*3
+ if u in ("m","meter","metre"): return val*3.28084
+ if u == "cm": return val*0.0328084
+ if u in ("in","inch","inches"):return val/12
+ return val # bare / ft / feet
+
+def scale_tokens(s, factor):
+ """multiply every numeric token so units + parentheticals scale together.
+ '4.5 yd (4.11 m)' *2 -> '9 yd (8.22 m)'; '9 yd | 8.2 m' *0.5 -> '4.5 yd | 4.1 m'."""
+ def fx(m):
+ x = float(m.group()) * factor
+ return str(int(x)) if abs(x-round(x)) < 1e-9 else f"{round(x,3):g}"
+ return re.sub(r"\d+(?:\.\d+)?", fx, str(s))
+
+PAGE = """
+query($vendor:String!,$cursor:String){
+ products(first:60, after:$cursor, query:$vendor){
+ pageInfo{ hasNextPage endCursor }
+ nodes{
+ id title handle status
+ w: metafield(namespace:"global", key:"width"){ value }
+ len: metafield(namespace:"global", key:"length"){ value }
+ sr: metafield(namespace:"global", key:"single_roll_length"){ value }
+ dr: metafield(namespace:"global", key:"double_roll_length"){ value }
+ uom: metafield(namespace:"global", key:"unit_of_measure"){ value }
+ pmin:metafield(namespace:"global", key:"v_prods_quantity_order_min"){ value }
+ pun: metafield(namespace:"global", key:"v_prods_quantity_order_units"){ value }
+ variants(first:25){ nodes{ id title sku
+ vmin: metafield(namespace:"custom", key:"v_prod_quantity_order_min"){ value }
+ vun: metafield(namespace:"custom", key:"v_prods_quantity_order_units"){ value } } }
+ }
+ }
+}"""
+SET = """
+mutation($mfs:[MetafieldsSetInput!]!){
+ metafieldsSet(metafields:$mfs){ metafields{ id } userErrors{ field message } }
+}"""
+
+def mf(owner, key, val):
+ return {"ownerId": owner, "namespace": "global", "key": key,
+ "type": "single_line_text_field", "value": val}
+
+def dwsku_of(variant_sku):
+ return re.sub(r"-(Roll|Sample)$", "", (variant_sku or ""), flags=re.I).strip().upper()
+
+def main():
+ catalog = load_catalog()
+ print(f">>> vendor={VKEY} (Shopify '{CFG['shopify_vendor']}') catalog {CFG['table']}.{CFG['len_col']}: {len(catalog)} rows")
+
+ prods, cursor = [], None
+ vq = f"vendor:'{CFG['shopify_vendor']}' status:active,draft"
+ while True:
+ d = gql(PAGE, {"vendor": vq, "cursor": cursor})["products"]
+ prods.extend(d["nodes"])
+ sys.stderr.write(f"\rscanned {len(prods)}")
+ if not d["pageInfo"]["hasNextPage"]: break
+ cursor = d["pageInfo"]["endCursor"]; time.sleep(0.25)
+ sys.stderr.write("\n")
+
+ inputs, var_inputs = [], [] # global mfs ; variant custom mfs (namespace custom)
+ scanned = roll = n_single = n_double = n_odd = corrections = 0
+ ms = lenw = copyw = 0
+ odd_samples, cls_samples = [], []
+ bf = open(BEFORE, "w")
+
+ for p in prods:
+ scanned += 1
+ pid = p["id"]
+ roll_vars = [v for v in p["variants"]["nodes"]
+ if ROLL_RE.search(v["title"] or "") and not EXCLUDE_RE.search(v["title"] or "")]
+ cur_sr = (p["sr"] or {}).get("value"); cur_dr = (p["dr"] or {}).get("value")
+ bf.write(json.dumps({"id": pid, "handle": p["handle"], "status": p["status"],
+ "width": (p["w"] or {}).get("value"), "length": (p["len"] or {}).get("value"),
+ "single_roll_length": cur_sr, "double_roll_length": cur_dr,
+ "uom": (p["uom"] or {}).get("value"),
+ "pmin": (p["pmin"] or {}).get("value"),
+ "roll_variants": [v["sku"] for v in roll_vars]}) + "\n")
+ if not roll_vars:
+ continue
+ roll += 1
+
+ # ---- min/step = 2 (idempotent), always for roll products ----
+ if (p["pmin"] or {}).get("value") != "2": inputs.append(mf(pid,"v_prods_quantity_order_min","2")); ms+=1
+ if (p["pun"] or {}).get("value") != "2": inputs.append(mf(pid,"v_prods_quantity_order_units","2")); ms+=1
+ for v in roll_vars:
+ if (v["vmin"] or {}).get("value") != "2":
+ var_inputs.append({"ownerId":v["id"],"namespace":"custom","key":"v_prod_quantity_order_min","type":"single_line_text_field","value":"2"}); ms+=1
+ if (v["vun"] or {}).get("value") != "2":
+ var_inputs.append({"ownerId":v["id"],"namespace":"custom","key":"v_prods_quantity_order_units","type":"single_line_text_field","value":"2"}); ms+=1
+
+ # ---- resolve width + candidate length: catalog first, else live global.length ----
+ dwsku = dwsku_of(roll_vars[0]["sku"])
+ cat_w, cat_L = catalog.get(dwsku, ("", ""))
+ W = cat_w or (p["w"] or {}).get("value") or ""
+ L = cat_L or (p["len"] or {}).get("value") or ""
+ wi = width_in(W); lf = length_ft(L)
+ if not (wi and lf and lf > 0):
+ n_odd += 1
+ if len(odd_samples) < 6: odd_samples.append((dwsku, W, L, "unparseable"))
+ continue
+ sqft = wi/12 * lf
+
+ if SINGLE_LO <= sqft <= SINGLE_HI:
+ n_single += 1; single = L; double = scale_tokens(L, 2.0); kind = "single"
+ elif DOUBLE_LO <= sqft <= DOUBLE_HI:
+ n_double += 1; single = scale_tokens(L, 0.5); double = L; kind = "bolt"
+ else:
+ n_odd += 1
+ if len(odd_samples) < 6: odd_samples.append((dwsku, W, L, f"{sqft:.0f}sqft"))
+ continue
+
+ if len(cls_samples) < 6:
+ cls_samples.append(f"{dwsku:16} {W:16} L={L!r:20} {sqft:.0f}sqft {kind}: S/R={single!r} D/R={double!r}")
+ # a "correction" = we're changing an existing double_roll_length to a different value
+ if cur_dr and cur_dr != double: corrections += 1
+
+ Wdisp = (p["w"] or {}).get("value") or W
+ if cur_sr != single: inputs.append(mf(pid,"single_roll_length",single)); lenw+=1
+ if cur_dr != double: inputs.append(mf(pid,"double_roll_length",double)); lenw+=1
+ copy = (f"Priced per single roll — packaged in double rolls only. "
+ f"Single roll: {Wdisp} × {single}. Double roll: {Wdisp} × {double}.")
+ if (p["uom"] or {}).get("value") != copy:
+ inputs.append(mf(pid,"unit_of_measure",copy)); copyw+=1
+ bf.close()
+
+ allmf = inputs + var_inputs
+ print(f"scanned {scanned} | in-scope ROLL {roll}")
+ print(f" classified SINGLE-sized (double=2x) : {n_single}")
+ print(f" classified BOLT-sized (single=/2) : {n_double}")
+ print(f" ODD / skipped (min/step only) : {n_odd}")
+ print(f" existing D/R values CORRECTED : {corrections}")
+ print(f"metafield writes queued: {len(allmf)} (min/step {ms} · S/R+D/R {lenw} · copy {copyw})")
+ print(f"before-state: {BEFORE}")
+ if cls_samples:
+ print("sample classifications:")
+ for s in cls_samples: print(" ", s)
+ if odd_samples:
+ print("sample ODD/skipped:")
+ for s in odd_samples: print(" ", s)
+
+ if not LIVE:
+ print("\nDRY RUN — no writes. Re-run with --live to apply.")
+ return
+ done = errs = 0
+ for i in range(0, len(allmf), 25):
+ batch = allmf[i:i+25]
+ d = gql(SET, {"mfs": batch})
+ ue = d["metafieldsSet"]["userErrors"]
+ if ue: errs += len(ue); print("userErrors:", ue[:3])
+ done += len(batch)
+ if done % 500 < 25: print(f" ...{done}/{len(allmf)}")
+ time.sleep(0.3)
+ print(f"DONE. wrote {done} metafields, userErrors={errs}")
+
+if __name__ == "__main__":
+ main()
diff --git a/shopify/theme-LIVE-591/snippets/product-description-meta.liquid b/shopify/theme-LIVE-591/snippets/product-description-meta.liquid
index dcfc2759..99214960 100644
--- a/shopify/theme-LIVE-591/snippets/product-description-meta.liquid
+++ b/shopify/theme-LIVE-591/snippets/product-description-meta.liquid
@@ -12,6 +12,8 @@
{% assign has_specs = false %}
{% assign w = product.metafields.custom.width.value | default: product.metafields.specs.width.value | default: product.metafields.global.Width.value | default: product.metafields.global.width.value %}
{% assign mat = product.metafields.custom.material.value | default: product.metafields.specs.composition.value | default: product.metafields.global.Content.value | default: product.metafields.global.Contents.value | default: product.metafields.global.Construction.value %}
+{% comment %} envelope-guard: unwrap a leaked raw metafield JSON wrapper e.g. {"type":"single_line_text_field","value":"Paper"} if one ever slips through, and prefer the canonical global.Material when the value is still an envelope {% endcomment %}
+{% if mat contains '"single_line_text_field"' and mat contains '"value"' %}{% assign mat = mat | split: '"value": "' | last | split: '"}' | first | default: product.metafields.global.Material.value %}{% endif %}
{% assign col = product.metafields.custom.collection_name.value | default: product.metafields.specs.collection.value | default: product.metafields.global.Collection.value %}
{% assign rep = product.metafields.custom.pattern_repeat.value | default: product.metafields.global.repeat.value | default: product.metafields.global['Vert-Rpt'].value %}
{% assign fin = product.metafields.custom.finish.value | default: product.metafields.specs.finish.value | default: product.metafields.global.Finish.value | default: product.metafields.global.FINISH.value %}
@@ -36,7 +38,15 @@
<div class="dw-specs">
<div class="dw-specs-title">Specifications</div>
{% if w != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Width</span><span class="dw-spec-value">{{ w }}</span></div>{% endif %}
- {% if len != blank %}<div class="dw-spec-row"><span class="dw-spec-label">{% if product.vendor == 'Designtex' %}Bolt size{% else %}Length{% endif %}</span><span class="dw-spec-value">{{ len }}</span></div>{% endif %}
+ {% comment %} Double-roll lines (Thibaut/Malibu Wallpaper/Malibu Walls/York): explicit S/R + D/R
+ length metafields (D/R = 2 x S/R) render directly so the theme never has to guess by halving
+ global.length. Falls back to the plain Length row for everything else. (Steve 2026-07-08 "2 and 2") {% endcomment %}
+ {% assign srlen = product.metafields.global.single_roll_length.value %}
+ {% assign drlen = product.metafields.global.double_roll_length.value %}
+ {% if srlen != blank and drlen != blank %}
+ <div class="dw-spec-row"><span class="dw-spec-label">Length S/R</span><span class="dw-spec-value">{{ srlen }}</span></div>
+ <div class="dw-spec-row"><span class="dw-spec-label">Length D/R</span><span class="dw-spec-value">{{ drlen }}</span></div>
+ {% elsif len != blank %}<div class="dw-spec-row"><span class="dw-spec-label">{% if product.vendor == 'Designtex' %}Bolt size{% else %}Length{% endif %}</span><span class="dw-spec-value">{{ len }}</span></div>{% endif %}
{% if rep != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Pattern Repeat</span><span class="dw-spec-value">{{ rep }}</span></div>{% endif %}
{% if match != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Match</span><span class="dw-spec-value">{{ match }}</span></div>{% endif %}
{% if mat != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Material</span><span class="dw-spec-value">{{ mat }}</span></div>{% endif %}
← 14e4dd9e auto-save: 2026-07-08T10:35:15 (3 files) — pending-approval/
·
back to Designer Wallcoverings
·
auto-save: 2026-07-08T11:05:21 (13 files) — pending-approval 2983280e →