← 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);});