← back to Flock Fix Viewer

enrich-rollout.mjs

84 lines

// Flock spec enrichment rollout: match each DW flock product to its Astek VV pattern,
// write verified specs (repeat per-pattern; match/print/backing/application/removal/material uniform)
// to BOTH global + custom namespaces. Reversible + ledgered. Modes: --dry | --apply
import fs from 'fs'; import os from 'os'; import path from 'path';
const SHOP='designer-laboratory-sandbox.myshopify.com';
const TOKEN=fs.readFileSync(new URL('./.token',import.meta.url),'utf8').trim();
const LEDGER=path.join(os.homedir(),'.claude/yolo-queue/executed-reversible/ledger.jsonl');
const DRY=!process.argv.includes('--apply');
const API=`https://${SHOP}/admin/api/2024-10`;
const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const gql=async(q,v={})=>(await (await fetch(`${API}/graphql.json`,{method:'POST',headers:H,body:JSON.stringify({query:q,variables:v})})).json());

// pattern key from a title/name: strip DW first-name, colorway, boilerplate
function patKey(t){return (t||'').toLowerCase()
  .replace(/^vv\d+\s*-\s*/,'').replace(/\s*\|.*$/,'')
  .replace(/memo sample|flocked? velvet|flocked? wallcovering|wallcovering|wallpaper|\bmemo\b|\bsample\b|damask/g,'')
  .replace(/^\w+['’]s\s+/,'')  // drop leading "Florence's "
  .replace(/[^a-z0-9 ]/g,' ').replace(/\s+/g,' ').trim();}

// Verified Astek VV-Flocked (Vogue Velour) per-pattern vertical repeat. EXACT longest-substring match.
const ASTEK={
  'st. moritz':'27.25 Inches','st moritz':'27.25 Inches',
  'madison':'36 Inches','empire':'43.5 Inches','griffons':'35 Inches',
  'zodiac':'32.5 Inches','art deco fans':'24 Inches','art deco':'24 Inches',
  'lattice':'39 Inches','grille':'25.5 Inches','medallions':'39 Inches',
};
const ASTEK_KEYS=Object.keys(ASTEK).sort((a,b)=>b.length-a.length);
function match1(s){ const t=(s||'').toLowerCase(); for(const k of ASTEK_KEYS){ if(t.includes(k)) return k; } return null; }
// Match on handle AND title; require agreement. Disagreement = mislabeled dup -> skip (don't guess).
function matchRepeat(handle,title){
  const h=match1(handle), t=match1(title);
  if(h && t && ASTEK[h]!==ASTEK[t]) return {conflict:true, h, t};   // e.g. handle=madison but title=empire
  const k=h||t; if(!k) return null;
  return {repeat:ASTEK[k], name:k};
}

const UNIFORM=[
  ['Match','match_type','Straight'],
  ['application','application','Paste the Paper'],
  ['Substrate','backing','Paper'],
  ['removability','removal','Strippable'],
  ['print_type','print_type','Flock'],
];
const UOM='Priced Per Single Roll. Ships untrimmed (30" untrimmed / 27" trimmed width).';

(async()=>{
  console.log('Astek patterns:',ASTEK_KEYS.length);
  // DW flock priced products
  let cur=null,all=[];
  const Q=`query($c:String){ products(first:100, query:"(tag:'Flock Velvet' OR title:flock OR handle:flock)", after:$c){ pageInfo{hasNextPage endCursor} edges{node{ id handle title status variants(first:6){edges{node{title price}}} }}}}`;
  do{const r=await gql(Q,{c:cur});const p=r?.data?.products;if(!p)break;all=all.concat(p.edges);cur=p.pageInfo.hasNextPage?p.pageInfo.endCursor:null;}while(cur);
  const priced=all.map(e=>e.node).filter(n=>n.status!=='ARCHIVED' && n.variants.edges.map(e=>e.node).some(v=>!/sample/i.test(v.title||'')&&parseFloat(v.price)>10));
  let matched=0,unmatched=[],conflicts=[],done=0;
  for(const n of priced){
    const hit=matchRepeat(n.handle,n.title);
    if(!hit){unmatched.push(n.handle);continue;}
    if(hit.conflict){conflicts.push(n.handle+' (handle='+hit.h+' vs title='+hit.t+')');continue;}
    matched++;
    const mf=[];
    // per-pattern repeat (global.repeat + custom.pattern_repeat)
    mf.push({ownerId:n.id,namespace:'global',key:'repeat',type:'single_line_text_field',value:hit.repeat});
    mf.push({ownerId:n.id,namespace:'custom',key:'pattern_repeat',type:'single_line_text_field',value:hit.repeat});
    // uniform specs to global + custom
    for(const [gk,ck,val] of UNIFORM){
      mf.push({ownerId:n.id,namespace:'global',key:gk,type:'single_line_text_field',value:val});
      mf.push({ownerId:n.id,namespace:'custom',key:ck,type:'single_line_text_field',value:val});
    }
    // fix uom in both namespaces
    mf.push({ownerId:n.id,namespace:'global',key:'unit_of_measure',type:'single_line_text_field',value:UOM});
    mf.push({ownerId:n.id,namespace:'custom',key:'unit_of_measure',type:'single_line_text_field',value:UOM});
    if(DRY){ if(matched<=8) console.log(`  ${n.handle.slice(0,42)} -> repeat ${hit.repeat} (${hit.name})`); continue; }
    const r=await gql('mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}',{mf});
    const errs=r?.data?.metafieldsSet?.userErrors;
    if(errs&&errs.length){console.log('  FAIL',n.handle,JSON.stringify(errs));continue;}
    done++;
    fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),agent:'main:flock-enrich-rollout',ticket:'flock-enrich',action:`enrich ${n.handle} (repeat ${hit.repeat} + uniform flock specs, global+custom)`,blast_radius:1,undo_cmd:`delete added spec metafields on ${n.id}`,verify:`spec table shows repeat/match/removal`})+'\n');
    if(done%10===0)console.log(`  …${done}/${matched}`);
    await sleep(220);
  }
  console.log(`${DRY?'DRY':'DONE'} · matched ${matched} · applied ${done} · unmatched ${unmatched.length} · conflicts ${conflicts.length}`);
  if(conflicts.length) console.log('  CONFLICTS (handle/title disagree — skipped, need manual):', conflicts.join(' | '));
})();