← back to Letsbegin

color-family-write.js

99 lines

#!/usr/bin/env node
/**
 * color-family-write.js — write custom.color_family (single_line_text) on each cohort
 * product, derived from the VENDOR motif color word in the live title (Steve 2026-06-11:
 * the vendor colorway word is canonical, NOT the AI ground-pixel color).
 *
 * - Reads color-family-cohort.jsonl (id, title, vendorColor, hex, color_family[current]).
 * - Maps vendorColor -> controlled family (color-family-map.js); falls back to HSL-bucketing
 *   custom.dominant_hex when the word isn't a recognized color.
 * - Idempotent + resumable: re-fetches current custom.color_family and SKIPS if already == target.
 * - Does NOT touch title/tags/cost/price or any other metafield. Keeps dominant_color/color_name.
 *
 * Flags:
 *   --limit N    only process first N cohort rows (validation)
 *   --dry        compute + print, no writes
 *   --ids a,b    restrict to specific gids (QA)
 */
const https = require('https');
const fs = require('fs');
const { mapVendorColorToFamily, hexToFamily } = require('./color-family-map.js');

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = (() => {
  const line = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8')
    .split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
  return line.split('=').slice(1).join('=').trim().replace(/^"|"$/g, '');
})();
const API = '/admin/api/2024-10/graphql.json';
const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key value } userErrors { message field } } }';

const args = process.argv.slice(2);
const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i + 1], 10) : Infinity; })();
const DRY = args.includes('--dry');
const ONLY = (() => { const i = args.indexOf('--ids'); return i >= 0 ? new Set(args[i + 1].split(',')) : null; })();

function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function gqlRaw(body){
  return new Promise((resolve,reject)=>{
    const data=JSON.stringify(body);
    const req=https.request({hostname:STORE,path:API,method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)}},
      res=>{let c='';res.on('data',d=>c+=d);res.on('end',()=>{try{resolve(JSON.parse(c));}catch{resolve({error:c.slice(0,300)});}});});
    req.on('error',reject); req.setTimeout(60000,()=>{req.destroy();reject(new Error('timeout'));});
    req.write(data); req.end();
  });
}
async function gql(body,retries=5){
  for(let a=1;a<=retries;a++){
    try{
      const r=await gqlRaw(body);
      if(r?.errors?.some(e=>/Throttled/i.test(e.message||''))){await sleep(3000);continue;}
      if((r?.extensions?.cost?.throttleStatus?.currentlyAvailable||9999)<200) await sleep(1500);
      return r;
    }catch(e){ if(a<retries){await sleep(a*2000);continue;} throw e; }
  }
}

function familyFor(rec){
  let { family, method } = mapVendorColorToFamily(rec.vendorColor);
  if(!family){ family = hexToFamily(rec.hex); method = rec.hex ? 'hex' : 'hex-nohex'; }
  return { family, method };
}

async function main(){
  let recs = fs.readFileSync('/Users/macstudio3/Projects/Letsbegin/color-family-cohort.jsonl','utf8')
    .trim().split('\n').map(l=>JSON.parse(l)).filter(r=>!r.missing);
  if(ONLY) recs = recs.filter(r=>ONLY.has(r.id));
  recs = recs.slice(0, LIMIT===Infinity ? recs.length : LIMIT);

  let wrote=0, skipped=0, fail=0;
  const distrib={}, methodCount={};
  for(let i=0;i<recs.length;i++){
    const r = recs[i];
    const { family, method } = familyFor(r);
    distrib[family]=(distrib[family]||0)+1;
    methodCount[method]=(methodCount[method]||0)+1;

    if(DRY){ console.log(`[dry] ${r.title}  ::  vendorColor="${r.vendorColor}"  ->  ${family}  (${method})`); continue; }

    // idempotent: re-fetch current color_family
    const cur = await gql({query:`{ product(id:"${r.id}"){ metafield(namespace:"custom",key:"color_family"){ value } } }`});
    const existing = cur?.data?.product?.metafield?.value || null;
    if(existing === family){ skipped++; if((i+1)%50===0) console.log(`  [${i+1}/${recs.length}] skip(${skipped}) wrote(${wrote})`); await sleep(80); continue; }

    const w = await gql({query:MF_MUTATION, variables:{ m:[{ ownerId:r.id, namespace:'custom', key:'color_family', value:family, type:'single_line_text_field' }] }});
    const errs = w?.data?.metafieldsSet?.userErrors || [];
    if(errs.length){ fail++; console.error(`  FAIL ${r.id} "${r.vendorColor}"->${family}: ${errs.map(e=>e.message).join('; ')}`); }
    else { wrote++; }
    if((i+1)%50===0) console.log(`  [${i+1}/${recs.length}] skip(${skipped}) wrote(${wrote}) fail(${fail})`);
    await sleep(180);
  }

  console.log('\n=== RUN SUMMARY ===');
  console.log(`processed=${recs.length} wrote=${wrote} skipped(idempotent)=${skipped} fail=${fail}`);
  console.log('method:', JSON.stringify(methodCount));
  console.log('distribution:');
  Object.entries(distrib).sort((a,b)=>b[1]-a[1]).forEach(([f,n])=>console.log('  '+String(n).padStart(4),f));
}
main().catch(e=>{console.error('FATAL',e);process.exit(1);});