← back to Dwjs Consolidation 2026 04 23
phase3_prepare_jeffrey_stevens.js
239 lines
#!/usr/bin/env node
/**
* PHASE 3 PREPARE — JEFFREY STEVENS PRIVATE-LABEL REBRAND (NO WRITES).
*
* Target set: ACTIVE Unknown DWJS products only (986 products, 776 patterns).
* Generates reviewable CSV for the user before any writes.
*
* Rename rules:
* - One coastal city name per pattern (e.g. "Claire Chevron Weave" → "Santorini")
* - New title: "<City> <custom.color> Wallcovering | Jeffrey Stevens"
* - Keep existing DWJS-##### SKUs (no renumber)
* - Keep vendor "Jeffrey Stevens"
* - Tags to add: custom.color, custom.color_family, specs.style, specs.pattern
* - No "Series:" tag (per user instruction)
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const STORE='designer-laboratory-sandbox.myshopify.com';
const TOKEN=(process.env.SHOPIFY_ADMIN_TOKEN || '');
const API='/admin/api/2024-10/graphql.json';
const OUT=__dirname;
function gql(body, retry=0) {
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 { if (retry<3) setTimeout(()=>resolve(gql(body,retry+1)), 2000); else resolve({error:c.slice(0,300)}); }
});}
);
req.on('error', err => { if (retry<3) setTimeout(()=>resolve(gql(body,retry+1)), 2000); else reject(err); });
req.setTimeout(60000, ()=>{ req.destroy(); if (retry<3) resolve(gql(body,retry+1)); else reject(new Error('t')); });
req.write(data); req.end();
});
}
async function bulkStart(filter) {
const q = `
{
products(query: "${filter}") {
edges { node {
id title status vendor tags
color: metafield(namespace:"custom", key:"color") { value }
color_family: metafield(namespace:"custom", key:"color_family") { value }
color_hex: metafield(namespace:"custom", key:"color_hex") { value }
background: metafield(namespace:"custom", key:"background_color") { value }
style: metafield(namespace:"specs", key:"style") { value }
pattern_mf: metafield(namespace:"specs", key:"pattern") { value }
pattern_name: metafield(namespace:"custom", key:"pattern_name") { value }
collection_name: metafield(namespace:"custom", key:"collection_name") { value }
} }
}
}`;
// wait for any prior bulk op to clear
for (let i=0; i<10; i++) {
const r = await gql({ query: `{ currentBulkOperation { status } }` });
const st = r?.data?.currentBulkOperation?.status;
if (!st || ['COMPLETED','FAILED','CANCELED','EXPIRED'].includes(st)) break;
await new Promise(r=>setTimeout(r,3000));
}
const r = await gql({ query:`mutation { bulkOperationRunQuery(query: """${q}""") { bulkOperation{id} userErrors{message} } }` });
if (r?.data?.bulkOperationRunQuery?.userErrors?.length) {
console.error('bulk start error', r.data.bulkOperationRunQuery.userErrors);
await new Promise(r=>setTimeout(r,8000));
return bulkStart(filter);
}
for (;;) {
await new Promise(r=>setTimeout(r,4000));
const p = await gql({ query: `{ currentBulkOperation { status objectCount url errorCode } }` });
const op = p?.data?.currentBulkOperation;
if (op?.status === 'COMPLETED') { console.log(`bulk done: ${op.objectCount} objects`); return op.url; }
if (['FAILED','CANCELED','EXPIRED'].includes(op?.status)) throw new Error(op.status);
}
}
function dl(url, file) {
return new Promise((resolve, reject) => {
const out = fs.createWriteStream(file);
https.get(url, res => { res.pipe(out); out.on('finish', () => out.close(() => resolve(file))); }).on('error', reject);
});
}
function parseProducts(file) {
const products = [];
for (const line of fs.readFileSync(file,'utf8').split('\n').filter(Boolean)) {
const o = JSON.parse(line);
if (o.id?.includes('/Product/')) {
products.push({
id: o.id,
title: o.title,
status: o.status,
vendor: o.vendor,
tags: o.tags || [],
color: o.color?.value || '',
color_family: o.color_family?.value || '',
color_hex: o.color_hex?.value || '',
background: o.background?.value || '',
style: o.style?.value || '',
pattern_mf: o.pattern_mf?.value || '',
pattern_name: o.pattern_name?.value || '',
collection_name: o.collection_name?.value || '',
});
}
}
return products;
}
// pattern root extractor (same as audit)
const COLOR = `(?:Cream|Ivory|Charcoal|Silver|Taupe|Sand|Oatmeal|Beige|Navy|Black|White|Blue|Green|Gold|Rose|Red|Pink|Teal|Gray|Grey|Brown|Champagne|Vanilla|Sage|Amber|Lilac|Coral|Wheat|Linen|Mustard|Mint|Ochre|Rust|Slate|Stone|Dove|Mocha|Espresso|Olive|Plum|Indigo|Burgundy|Aqua|Turquoise|Lavender|Copper|Bronze|Ash|Multi|Light|Dark|Soft|Warm|Cool|Pale|Deep|Bright|Natural|Metallic|Tan|Caramel|Cocoa|Peach|Apricot|Maroon|Crimson|Scarlet|Peacock|Sea|Sky|Midnight|Pearl|Ecru)`;
const TYPES = `(?:Wallcovering|Wallpaper|Fabric|Mural|Covering|Grasscloth)`;
const TRAIL = new RegExp(`((?:\\s+${COLOR}(?:\\s*(?:&|and)\\s*${COLOR})*)?\\s+${TYPES}(?:\\s*\\|.*)?)$`, 'i');
const COLOR_ONLY = new RegExp(`\\s+${COLOR}(?:\\s*(?:&|and)\\s*${COLOR})*$`, 'i');
function patternRoot(title) {
let t = (title||'').split('|')[0].trim();
for (let i=0; i<5; i++) { const n = t.replace(TRAIL,'').trim(); if (n===t||!n) break; t=n; }
for (let i=0; i<5; i++) { const n = t.replace(COLOR_ONLY,'').trim(); if (n===t||!n) break; t=n; }
return t.replace(/[ \-|&]+$/,'').trim();
}
// --- MAIN ---
(async () => {
const FILTER = 'sku:DWJS* AND status:active';
console.log('fetching active DWJS products...');
const url = await bulkStart(FILTER);
const file = path.join(OUT,'phase3_raw.jsonl');
await dl(url, file);
const all = parseProducts(file);
console.log(`pulled ${all.length} active DWJS products`);
// Filter to Unknown (no York/Brewster tag, no clear vendor-York)
const isYork = p => /^(york|york contract)$/i.test(p.vendor||'')
|| p.tags.some(t => /^(york|york contract|restoration elements|candice olson|stacy garcia|magnolia|ashford|hytex)/i.test(t));
const isBrewster = p => p.tags.some(t => /^(brewster)/i.test(t))
|| /dwbr-/i.test(p.id);
const targets = all.filter(p => !isYork(p) && !isBrewster(p));
console.log(`unknown/jeffrey-stevens-private-label targets: ${targets.length}`);
// Group by pattern root (prefer custom.pattern_name if it's shorter / cleaner)
const groups = new Map();
for (const p of targets) {
let root = patternRoot(p.pattern_name || p.title);
// fallback
if (!root) root = patternRoot(p.title);
if (!root) root = p.title.split('|')[0].trim();
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(p);
}
console.log(`unique pattern roots: ${groups.size}`);
// Load coastal cities
const cityLines = fs.readFileSync(path.join(OUT,'coastal_cities.txt'),'utf8').split('\n');
const cities = [];
for (const l of cityLines) {
const s = l.trim();
if (!s || s.startsWith('#')) continue;
cities.push(s);
}
// dedupe, preserve order
const seen=new Set(); const cityPool = [];
for (const c of cities) { if (!seen.has(c)) { seen.add(c); cityPool.push(c); } }
console.log(`coastal city pool: ${cityPool.length}`);
if (groups.size > cityPool.length) {
console.error(`NOT ENOUGH CITIES — need ${groups.size}, have ${cityPool.length}`);
process.exit(1);
}
// Sort pattern roots alphabetically for stable assignment
const patternList = [...groups.keys()].sort();
const cityAssignment = {};
patternList.forEach((p, i) => { cityAssignment[p] = cityPool[i]; });
// Generate output rows
const cols = ['product_id','current_title','pattern_root','city','new_title','color','color_family','color_hex','style','pattern_mf','tags_to_add','current_status','vendor'];
const csvRows = [cols.join(',')];
const jsonRows = [];
const csvEsc = s => { if (s==null) return ''; const str=String(s); return /[",\n]/.test(str) ? '"'+str.replace(/"/g,'""')+'"' : str; };
for (const [root, products] of groups) {
const city = cityAssignment[root];
for (const p of products) {
const color = p.color || p.color_family || '';
const colorPart = color ? color : '';
// New title: "<City> <Color> Wallcovering | Jeffrey Stevens"
const newTitle = [city, colorPart, 'Wallcovering'].filter(Boolean).join(' ') + ' | Jeffrey Stevens';
// Tags to add (idempotent, will skip if already present when applied)
const newTags = [];
if (p.color) newTags.push(p.color);
if (p.color_family) newTags.push(p.color_family);
if (p.style) newTags.push(p.style);
if (p.pattern_mf) newTags.push(p.pattern_mf);
// Deduplicate against existing tags
const existing = new Set(p.tags.map(t => t.toLowerCase()));
const tagsToAdd = [...new Set(newTags.filter(t => t && !existing.has(t.toLowerCase())))];
const row = {
product_id: p.id.replace('gid://shopify/Product/',''),
current_title: p.title,
pattern_root: root,
city,
new_title: newTitle,
color: p.color,
color_family: p.color_family,
color_hex: p.color_hex,
style: p.style,
pattern_mf: p.pattern_mf,
tags_to_add: tagsToAdd.join('|'),
current_status: p.status,
vendor: p.vendor,
};
jsonRows.push(row);
csvRows.push(cols.map(c => csvEsc(row[c])).join(','));
}
}
fs.writeFileSync(path.join(OUT,'phase3_rename.csv'), csvRows.join('\n'));
fs.writeFileSync(path.join(OUT,'phase3_rename.json'), JSON.stringify(jsonRows, null, 2));
fs.writeFileSync(path.join(OUT,'phase3_pattern_cities.json'), JSON.stringify(cityAssignment, null, 2));
// Summary
console.log(`\n=== PHASE 3 PREPARE SUMMARY ===`);
console.log(`active target products: ${jsonRows.length}`);
console.log(`patterns → cities: ${Object.keys(cityAssignment).length}`);
console.log('\nsample rows (first 15):');
for (const r of jsonRows.slice(0,15)) {
console.log(` ${r.current_title.slice(0,50).padEnd(50)} → ${r.new_title}`);
}
console.log(`\nFiles:`);
console.log(` phase3_rename.csv — reviewable`);
console.log(` phase3_rename.json — phase-3 writer input`);
console.log(` phase3_pattern_cities.json — pattern→city assignment`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });