← back to Designer Wallcoverings
Designtex: Sold Per Yard variant relabel + Bolt size (spec-table relabel staged on dev 5.6.0 theme)
b1cc8522d45381f8c07cf804cde395f261f49c13 · 2026-06-26 10:27:02 -0700 · steve@designerwallcoverings.com
Files touched
A audits/designtex-uom-fix/designtex-soldperyard-boltsize.mjsA shopify/push-designtex-boltsize-to-dev-theme.sh
Diff
commit b1cc8522d45381f8c07cf804cde395f261f49c13
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Fri Jun 26 10:27:02 2026 -0700
Designtex: Sold Per Yard variant relabel + Bolt size (spec-table relabel staged on dev 5.6.0 theme)
---
.../designtex-soldperyard-boltsize.mjs | 98 ++++++++++++++++++++++
shopify/push-designtex-boltsize-to-dev-theme.sh | 44 ++++++++++
2 files changed, 142 insertions(+)
diff --git a/audits/designtex-uom-fix/designtex-soldperyard-boltsize.mjs b/audits/designtex-uom-fix/designtex-soldperyard-boltsize.mjs
new file mode 100644
index 00000000..0467b520
--- /dev/null
+++ b/audits/designtex-uom-fix/designtex-soldperyard-boltsize.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+// Designtex line-wide relabel (Steve-directed 2026-06-26):
+// - purchase variant title -> "Sold Per Yard" (all 356 DWDX, uniform)
+// - description prose "Priced per single roll" -> "Priced per yard",
+// "Sold By: Single Roll" -> "Sold By: yard"
+// - "Bolt size: <length>" <li> added where global.length exists & not present
+// - tag hygiene: drop "Priced Per Single Roll", ensure "Priced Per Yard"
+// Price + UOM metafield (already YARD) UNTOUCHED except idempotent re-assert.
+// GATED pattern, relabel-only. Dry-run by default; --execute to write. $0 (Admin API, no AI).
+import fs from 'fs'; import os from 'os'; import path from 'path';
+const EXECUTE = process.argv.includes('--execute');
+const env = fs.readFileSync(path.join(os.homedir(),'Projects/secrets-manager/.env'),'utf8');
+const tok = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
+const DOMAIN='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const HERE = path.dirname(new URL(import.meta.url).pathname);
+const gql=(q,v)=>fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':tok,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})}).then(r=>r.json());
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const TARGET='Sold Per Yard';
+
+// ---- fetch all DWDX ----
+const listQ=`query($q:String!,$after:String){ products(first:60,query:$q,after:$after){ pageInfo{hasNextPage endCursor} edges{ node{ id title tags descriptionHtml
+ metafield(namespace:"global",key:"length"){ value }
+ variants(first:8){ edges{ node{ id title } } } } } } }`;
+let after=null, prods=[];
+do{ const r=await gql(listQ,{q:'sku:DWDX-*',after}); if(r.errors){console.error(JSON.stringify(r.errors));process.exit(1);} const p=r.data.products;
+ prods.push(...p.edges.map(e=>e.node)); after=p.pageInfo.hasNextPage?p.pageInfo.endCursor:null;
+ process.stderr.write(`fetched ${prods.length}\r`);
+}while(after);
+console.log(`\nDWDX products: ${prods.length}`);
+
+// ---- compute per-product change plan ----
+function fixDesc(html, length){
+ let b = html || ''; const before=b;
+ b = b.replace(/Priced per single roll/gi, 'Priced per yard');
+ b = b.replace(/(<strong>\s*Sold By:\s*<\/strong>)\s*Single Roll/gi, '$1 yard');
+ b = b.replace(/Sold By:\s*Single Roll/gi, 'Sold By: yard');
+ // insert Bolt size li before the Sold By li, if length known & not already present
+ if (length && !/Bolt size\s*:/i.test(b)) {
+ const li = `<li>\n<strong>Bolt size:</strong> ${length}</li>`;
+ if (/<li>\s*<strong>\s*Sold By:/i.test(b)) {
+ b = b.replace(/(<li>\s*<strong>\s*Sold By:)/i, li + '\n$1');
+ }
+ }
+ return { html: b, changed: b !== before };
+}
+const plan=[];
+for(const n of prods){
+ const prodVars = n.variants.edges.map(e=>e.node).filter(v=>!/Sample/i.test(v.title));
+ const varChanges = prodVars.filter(v=>v.title!==TARGET);
+ const d = fixDesc(n.descriptionHtml, n.metafield?.value);
+ const tags = n.tags||[];
+ const tagRm = tags.includes('Priced Per Single Roll');
+ const tagAdd = !tags.includes('Priced Per Yard');
+ if (varChanges.length || d.changed || tagRm || tagAdd)
+ plan.push({ id:n.id, title:n.title, varIds:varChanges.map(v=>v.id), oldVar:prodVars.map(v=>v.title), newDesc:d.changed?d.html:null, tagRm, tagAdd });
+}
+const cnt = {
+ variant: plan.filter(p=>p.varIds.length).length,
+ fromSingleRoll: plan.filter(p=>p.oldVar.some(t=>/single roll/i.test(t))).length,
+ desc: plan.filter(p=>p.newDesc).length,
+ tagRm: plan.filter(p=>p.tagRm).length, tagAdd: plan.filter(p=>p.tagAdd).length,
+};
+console.log(`\nCHANGE PLAN (${plan.length} products touched):`);
+console.log(` variant title -> "${TARGET}": ${cnt.variant} (of which from "Single Roll": ${cnt.fromSingleRoll})`);
+console.log(` description rewrite: ${cnt.desc}`);
+console.log(` tag remove "Priced Per Single Roll": ${cnt.tagRm}`);
+console.log(` tag add "Priced Per Yard": ${cnt.tagAdd}`);
+console.log('\nSample (first 3):');
+plan.slice(0,3).forEach(p=>console.log(` · ${p.title} | var ${JSON.stringify(p.oldVar)}->"${TARGET}" | desc:${!!p.newDesc} | tagRm:${p.tagRm} tagAdd:${p.tagAdd}`));
+fs.writeFileSync(path.join(HERE,'soldperyard-plan.json'), JSON.stringify(plan,null,2));
+
+if(!EXECUTE){ console.log(`\nDRY RUN. ${plan.length} products would change. Re-run with --execute.`); process.exit(0); }
+
+// ---- execute ----
+const mfMut=`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ message } } }`;
+const tagRmM=`mutation($id:ID!,$tags:[String!]!){ tagsRemove(id:$id,tags:$tags){ userErrors{ message } } }`;
+const tagAddM=`mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id,tags:$tags){ userErrors{ message } } }`;
+const varMut=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid,variants:$variants){ userErrors{ message } } }`;
+const descMut=`mutation($id:ID!,$d:String!){ productUpdate(input:{id:$id,descriptionHtml:$d}){ userErrors{ message } } }`;
+const results=[]; const BATCH=40;
+for(let i=0;i<plan.length;i+=BATCH){
+ for(const p of plan.slice(i,i+BATCH)){
+ const errs=[];
+ if(p.varIds.length){ const vm=await gql(varMut,{pid:p.id,variants:p.varIds.map(id=>({id,optionValues:[{optionName:'Title',name:TARGET}]}))});
+ (vm.data?.productVariantsBulkUpdate?.userErrors||[]).forEach(e=>errs.push('var:'+e.message)); if(vm.errors)errs.push('var:'+JSON.stringify(vm.errors)); }
+ if(p.newDesc){ const dm=await gql(descMut,{id:p.id,d:p.newDesc}); (dm.data?.productUpdate?.userErrors||[]).forEach(e=>errs.push('desc:'+e.message)); if(dm.errors)errs.push('desc:'+JSON.stringify(dm.errors)); }
+ if(p.tagRm){ const t=await gql(tagRmM,{id:p.id,tags:['Priced Per Single Roll']}); (t.data?.tagsRemove?.userErrors||[]).forEach(e=>errs.push('tagRm:'+e.message)); }
+ if(p.tagAdd){ const t=await gql(tagAddM,{id:p.id,tags:['Priced Per Yard']}); (t.data?.tagsAdd?.userErrors||[]).forEach(e=>errs.push('tagAdd:'+e.message)); }
+ await gql(mfMut,{mf:[{ownerId:p.id,namespace:'global',key:'unit_of_measure',type:'single_line_text_field',value:'YARD'}]});
+ results.push({title:p.title,ok:errs.length===0,errs}); if(errs.length)console.error('ERR',p.title,errs.join('; '));
+ await sleep(350);
+ }
+ console.log(`Batch ${i/BATCH+1} done (${Math.min(i+BATCH,plan.length)}/${plan.length}).`);
+ if(i+BATCH<plan.length){ console.log('Sleeping 60s (bulk-push gap)...'); await sleep(60000); }
+}
+const failed=results.filter(r=>!r.ok);
+console.log(`\nDONE. ${results.length-failed.length} ok, ${failed.length} failed.`);
+fs.writeFileSync(path.join(HERE,'soldperyard-results.json'),JSON.stringify(results,null,2));
diff --git a/shopify/push-designtex-boltsize-to-dev-theme.sh b/shopify/push-designtex-boltsize-to-dev-theme.sh
new file mode 100755
index 00000000..849a1c3c
--- /dev/null
+++ b/shopify/push-designtex-boltsize-to-dev-theme.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Stage Designtex "Length -> Bolt size" spec relabel on the DEV 5.6.0 theme ONLY.
+# Vendor-scoped: Designtex => "Bolt size"; every other vendor keeps "Length".
+# Edits snippets/product-description-meta.liquid (on-page spec row) +
+# snippets/spec-sheet-button.liquid (PDF). Backs up, pushes to DEV, verifies,
+# prints preview URL. Live/main theme (5.6.0) UNTOUCHED — going live is a
+# separate Steve-gated push. Idempotent (no-op if 'Bolt size' already present).
+set -euo pipefail
+cd "$(dirname "$0")"
+STORE="designer-laboratory-sandbox.myshopify.com"; API="2024-10"
+SECRETS="$HOME/Projects/secrets-manager/.env"
+DEV_TID="${DEV_TID:-144000057395}" # Steves Version 5.6.0 [Dev] DownloadBtn-by-Price
+TOK="${THEME_TOKEN:-$(grep -hE '^SHOPIFY_THEME_TOKEN=' "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'"' ')}"
+[ -z "$TOK" ] && { echo "No theme token. Aborting."; exit 1; }
+echo "theme token …${TOK: -4} | dev theme $DEV_TID"
+mkdir -p theme-backups
+python3 - "$STORE" "$API" "$TOK" "$DEV_TID" "theme-backups" <<'PY'
+import sys,json,urllib.request,urllib.parse,datetime
+store,api,tok,tid,bkdir=sys.argv[1:6]
+H={"X-Shopify-Access-Token":tok,"Content-Type":"application/json"}
+def get(u): return json.load(urllib.request.urlopen(urllib.request.Request(u,headers=H)))
+def put(b): return json.load(urllib.request.urlopen(urllib.request.Request(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json",data=json.dumps(b).encode(),headers=H,method="PUT")))
+# guard: never the main theme
+role=[t for t in get(f"https://{store}/admin/api/{api}/themes.json")["themes"] if str(t["id"])==str(tid)]
+if role and role[0]["role"]=="main": print("REFUSING: target is main theme."); sys.exit(1)
+GUARD="{% if product.vendor == 'Designtex' %}Bolt size{% else %}Length{% endif %}"
+GUARDC="{% if product.vendor == 'Designtex' %}Bolt size:{% else %}Length:{% endif %}"
+edits=[
+ ("snippets/product-description-meta.liquid",'<span class="dw-spec-label">Length</span>',f'<span class="dw-spec-label">{GUARD}</span>'),
+ ("snippets/spec-sheet-button.liquid",'<strong>Length:</strong>',f'<strong>{GUARDC}</strong>'),
+]
+stamp=datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+for key,old,new in edits:
+ q=urllib.parse.urlencode({"asset[key]":key})
+ try: cur=get(f"https://{store}/admin/api/{api}/themes/{tid}/assets.json?{q}")["asset"]["value"]
+ except urllib.error.HTTPError as e: print(f" {key}: fetch {e.code} — skip"); continue
+ if "Bolt size" in cur: print(f" {key}: already has Bolt size (no-op)"); continue
+ if old not in cur: print(f" {key}: label string not found — SKIP (no write)"); continue
+ open(f"{bkdir}/{key.replace('/','_')}.dev-{tid}.{stamp}.bak","w").write(cur)
+ res=put({"asset":{"key":key,"value":cur.replace(old,new,1)}})
+ print(f" {key}: PUT ok={'Bolt size' in res.get('asset',{}).get('value','')}")
+print(f"\nPREVIEW Designtex PDP: https://www.designerwallcoverings.com/products/wannabe-rib-mist-dwdx-220216?preview_theme_id={tid}")
+print("Going live = separate Steve-gated push to main 5.6.0.")
+PY
← c0339729 Scrub residual Newwall leak from metafields: global.Brand->r
·
back to Designer Wallcoverings
·
Fix metafield-scrub delete to use metafieldsDelete (collecti 01415495 →