← back to Designer Wallcoverings
audits/designtex-uom-fix/designtex-soldperyard-boltsize.mjs
99 lines
#!/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));