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