← back to Contractwallpaper
tmp-kravet-finish-collection-clean.mjs
88 lines
#!/usr/bin/env node
// Kravet metafield cleanup — normalize global.Finish (strip trailing ';' artifacts, split internal ';' -> ', ', Title Case)
// and Title-Case global.Collection (preserving Roman numerals). Overwrite-authoritative, ledger-resumable.
// Touches ONLY global.Finish + global.Collection on active Kravet-family products. Prices/other fields untouched.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
const env = Object.fromEntries(fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8')
.split('\n').filter(l=>l.includes('=')).map(l=>{const i=l.indexOf('='); return [l.slice(0,i).trim(), l.slice(i+1).trim()];}));
const DOMAIN=(env.SHOPIFY_STORE_DOMAIN||env.SHOPIFY_STORE).replace(/^https?:\/\//,'').replace(/\/$/,'');
const GQL=`https://${DOMAIN}/admin/api/2024-10/graphql.json`;
const H={'X-Shopify-Access-Token':env.SHOPIFY_ADMIN_TOKEN,'Content-Type':'application/json'};
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const DRY = !process.argv.includes('--apply');
const LIMIT = (()=>{const i=process.argv.indexOf('--limit'); return i>=0?Number(process.argv[i+1]):null;})();
// --- transforms ---
const SMALL=new Set(["and","or","the","of","in","a","an","for","to","with","by"]);
const ROMAN=/^(?:I{1,3}|IV|VI{0,3}|IX|XI{0,3})$/; // I..XIII, the realistic collection numerals
const capWord=p=>!p?p:(/^\d/.test(p)?p:p[0].toUpperCase()+p.slice(1).toLowerCase());
function titleCase(s){
return String(s).split(/\s+/).map((w,i)=>{
if(!w) return w;
const low=w.toLowerCase();
if(i>0 && SMALL.has(low)) return low;
if(ROMAN.test(w)) return w.toUpperCase(); // standalone Roman numeral (Garden II)
if(w.includes('&')){ // &-token: preserve short all-caps initialism parts
return w.split(/([&\-])/).map(part =>
(part==='&'||part==='-') ? part
: /^[A-Z0-9]{1,3}$/.test(part) ? part // B, GP, J
: capWord(part)).join('');
}
return w.split('-').map(capWord).join('-'); // normal token, hyphen-aware
}).join(" ").replace(/\s+/g," ").trim();
}
const cleanFinish=v=>String(v).split(";").map(t=>t.trim()).filter(Boolean).map(titleCase).join(", ");
async function gql(query,variables,tries=0){
const r=await fetch(GQL,{method:'POST',headers:H,body:JSON.stringify({query,variables})});
if(r.status===429||r.status===502||r.status===503){ if(tries<6){await sleep(1500*(tries+1)); return gql(query,variables,tries+1);} }
const j=await r.json();
if(j.errors){ if(tries<6 && JSON.stringify(j.errors).includes('Throttled')){await sleep(2000*(tries+1)); return gql(query,variables,tries+1);} throw new Error(JSON.stringify(j.errors).slice(0,180)); }
return j.data;
}
// same target set as the backfill
const rows=execFileSync('psql',['-h','/tmp','-d','dw_unified','-tA','-F','|','-c',`
SELECT s.sku, replace(s.shopify_id,'gid://shopify/Product/',''), upper(s.mfr_sku)
FROM shopify_products s
WHERE s.status='ACTIVE' AND EXISTS(SELECT 1 FROM kravet_feed_stage f WHERE upper(f.c1)=upper(s.mfr_sku))
AND s.vendor ILIKE ANY(ARRAY['%brunschwig%','%lee jofa%','%kravet%','%cole%','%baker%','%mulberry%','%clarke%','%groundworks%','%colefax%','%threads%','%andrew martin%','%aerin%','%barclay%','%thom filicia%'])
ORDER BY s.sku ${LIMIT?`LIMIT ${LIMIT}`:''};`]).toString().trim().split('\n').map(l=>{const[sku,id,mfr]=l.split('|');return{sku,id,mfr};});
const MF_LEDGER='/Users/macstudio3/kravet-feed-pull/finish-collection-done.txt';
const done=new Set(fs.existsSync(MF_LEDGER)?fs.readFileSync(MF_LEDGER,'utf8').trim().split('\n').filter(Boolean):[]);
const rows2=rows.filter(r=>!done.has(r.sku));
// bulk-load feed c14 (Finish) + c17 (Collection) keyed by mfr
const wanted=[...new Set(rows2.map(r=>r.mfr))];
const feed={};
for(let i=0;i<wanted.length;i+=2000){
const chunk=wanted.slice(i,i+2000).map(m=>`'${m.replace(/'/g,"''")}'`).join(',');
const out=execFileSync('psql',['-h','/tmp','-d','dw_unified','-tA','-F','\t','-c',
`SELECT upper(c1), c14, c17 FROM kravet_feed_stage WHERE upper(c1) IN (${chunk});`],{maxBuffer:1<<28}).toString().trim();
for(const line of out.split('\n')){ if(!line)continue; const [k,c14,c17]=line.split('\t'); feed[k]=[c14,c17]; }
}
console.log(`targets:${rows2.length} (of ${rows.length}, ${done.size} done) feed:${Object.keys(feed).length} ${DRY?'[DRY RUN]':''}`);
let ok=0,fail=0,fSet=0,cSet=0,shown=0;
for(const {sku,id,mfr} of rows2){
try{
const vals=feed[mfr]; if(!vals){fail++; if(!DRY)fs.appendFileSync(MF_LEDGER,sku+'\n'); continue;}
const [rawFin,rawColl]=vals;
const mfs=[];
let fClean,cClean;
if(rawFin!=null && String(rawFin).trim() && String(rawFin).toUpperCase()!=='NULL'){ fClean=cleanFinish(rawFin);
if(fClean) mfs.push({ownerId:`gid://shopify/Product/${id}`,namespace:'global',key:'Finish',type:'single_line_text_field',value:fClean.slice(0,255)}); }
if(rawColl!=null && String(rawColl).trim() && String(rawColl).toUpperCase()!=='NULL'){ cClean=titleCase(rawColl);
if(cClean) mfs.push({ownerId:`gid://shopify/Product/${id}`,namespace:'global',key:'Collection',type:'single_line_text_field',value:cClean.slice(0,255)}); }
if(DRY){ if(shown<12 && (fClean||cClean)){ console.log(` ${sku}: Finish "${rawFin}"->"${fClean}" Collection "${rawColl}"->"${cClean}"`); shown++; } if(fClean)fSet++; if(cClean)cSet++; ok++; continue; }
if(mfs.length){ const d=await gql(`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{message}}}`,{m:mfs});
const errs=d.metafieldsSet.userErrors; if(errs.length) throw new Error(JSON.stringify(errs).slice(0,160)); }
if(fClean)fSet++; if(cClean)cSet++; ok++; fs.appendFileSync(MF_LEDGER,sku+'\n');
if(ok%300===0) console.log(` ...${ok}/${rows2.length} (Finish:${fSet} Collection:${cSet} fail:${fail})`);
await sleep(90);
}catch(e){ fail++; if(fail<=20)console.error(` FAIL ${sku}: ${e.message}`); }
}
console.log(`\n${DRY?'DRY':'FINISH/COLLECTION'} DONE. products:${ok} Finish set:${fSet} Collection set:${cSet} failures:${fail}`);