← back to Dwjs Consolidation 2026 04 23

phase3_prepare_v2.js

300 lines

#!/usr/bin/env node
/**
 * PHASE 3 PREPARE v2 — JEFFREY STEVENS REBRAND (NO WRITES).
 *
 * Changes from v1:
 *  - Exclude ALL Fabric products (go into a separate review bucket)
 *  - Dedupe within-pattern title collisions by appending secondary color or hex suffix
 *
 * Inputs: raw bulk of `sku:DWJS* AND status:active` plus Fabric detection via
 *   title substring AND productType.
 */
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 productType
          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 }
          color_details: metafield(namespace:"custom", key:"color_details") { 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 }
        } }
      }
    }`;
  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 || [],
        productType: o.productType || '',
        color: o.color?.value || '',
        color_family: o.color_family?.value || '',
        color_hex: o.color_hex?.value || '',
        background: o.background?.value || '',
        color_details: o.color_details?.value || '',
        style: o.style?.value || '',
        pattern_mf: o.pattern_mf?.value || '',
        pattern_name: o.pattern_name?.value || '',
      });
    }
  }
  return products;
}

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

function isFabric(p) {
  const t = (p.productType||'').toLowerCase();
  const ti = (p.title||'').toLowerCase();
  if (t === 'fabric') return true;
  if (/\bfabric\b/.test(ti)) return true;
  return false;
}

function secondaryColor(p) {
  // parse color_details JSON for 2nd color name
  try {
    const arr = JSON.parse(p.color_details || '[]');
    if (Array.isArray(arr) && arr.length >= 2) return (arr[1].name || '').trim();
  } catch {}
  // fall back: background_color if different from primary
  if (p.background && p.background.toLowerCase() !== (p.color||'').toLowerCase()) return p.background;
  return '';
}

function hexTail(hex) {
  if (!hex) return '';
  const clean = hex.replace(/^#/,'');
  return clean.slice(-3).toUpperCase();
}

(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_v2_raw.jsonl');
  await dl(url, file);
  const all = parseProducts(file);
  console.log(`pulled ${all.length} active DWJS products`);

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

  const fabrics = [];
  const rebrand = [];
  for (const p of all) {
    if (isYork(p) || isBrewster(p)) continue;
    if (isFabric(p)) { fabrics.push(p); continue; }
    rebrand.push(p);
  }
  console.log(`rebrand target (non-York/Brewster, non-Fabric): ${rebrand.length}`);
  console.log(`excluded Fabric (to review bucket): ${fabrics.length}`);

  // Group by pattern root
  const groups = new Map();
  for (const p of rebrand) {
    let root = patternRoot(p.pattern_name || p.title) || patternRoot(p.title) || p.title.split('|')[0].trim();
    if (!groups.has(root)) groups.set(root, []);
    groups.get(root).push(p);
  }
  console.log(`unique pattern roots (non-fabric, non-York/Brewster): ${groups.size}`);

  // Load coastal cities
  const cityLines = fs.readFileSync(path.join(OUT,'coastal_cities.txt'),'utf8').split('\n');
  const cityPool = [];
  const seen = new Set();
  for (const l of cityLines) {
    const s = l.trim();
    if (!s || s.startsWith('#')) continue;
    if (!seen.has(s)) { seen.add(s); cityPool.push(s); }
  }
  console.log(`coastal city pool: ${cityPool.length}`);
  if (groups.size > cityPool.length) { console.error('NOT ENOUGH CITIES'); process.exit(1); }

  const patternList = [...groups.keys()].sort();
  const cityAssignment = {};
  patternList.forEach((p,i) => cityAssignment[p] = cityPool[i]);

  // Build titles, dedupe within group
  const cols = ['product_id','current_title','product_type','pattern_root','city','new_title','color','color_secondary','color_family','color_hex','style','pattern_mf','tags_to_add','status'];
  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];
    // First pass: compute base title
    const baseTitles = products.map(p => {
      const color = (p.color || p.color_family || '').trim();
      return { p, color, title: `${city} ${color} Wallcovering | Jeffrey Stevens`.replace(/\s+/g,' ') };
    });
    // Count dups by base title
    const counts = {};
    for (const b of baseTitles) counts[b.title] = (counts[b.title]||0)+1;

    for (const b of baseTitles) {
      let finalTitle = b.title;
      const needsDedup = counts[b.title] > 1;
      let secondary = '';
      if (needsDedup) {
        secondary = secondaryColor(b.p);
        if (secondary) {
          finalTitle = `${city} ${b.color} ${secondary} Wallcovering | Jeffrey Stevens`;
        } else {
          const ht = hexTail(b.p.color_hex);
          finalTitle = ht
            ? `${city} ${b.color} ${ht} Wallcovering | Jeffrey Stevens`
            : `${city} ${b.color} Wallcovering | Jeffrey Stevens`; // will still dup; flag
        }
      }
      // Final dedup guard: append incremental suffix if still collides inside group
      // (handle 3+ dups with same secondary)
      const existing = jsonRows.filter(r => r.pattern_root === root && r.new_title === finalTitle);
      if (existing.length > 0) {
        finalTitle = finalTitle.replace(' Wallcovering |', ` ${existing.length+1} Wallcovering |`);
      }

      const newTags = [];
      if (b.p.color) newTags.push(b.p.color);
      if (b.p.color_family) newTags.push(b.p.color_family);
      if (b.p.style) newTags.push(b.p.style);
      if (b.p.pattern_mf) newTags.push(b.p.pattern_mf);
      const existingTags = new Set(b.p.tags.map(t=>t.toLowerCase()));
      const tagsToAdd = [...new Set(newTags.filter(t => t && !existingTags.has(t.toLowerCase())))];

      const row = {
        product_id: b.p.id.replace('gid://shopify/Product/',''),
        current_title: b.p.title,
        product_type: b.p.productType,
        pattern_root: root,
        city,
        new_title: finalTitle,
        color: b.p.color,
        color_secondary: secondary,
        color_family: b.p.color_family,
        color_hex: b.p.color_hex,
        style: b.p.style,
        pattern_mf: b.p.pattern_mf,
        tags_to_add: tagsToAdd.join('|'),
        status: b.p.status,
      };
      jsonRows.push(row);
      csvRows.push(cols.map(c => csvEsc(row[c])).join(','));
    }
  }

  // Fabric review bucket
  const fabRows = [['product_id','title','product_type','color','tags'].join(',')];
  for (const p of fabrics) {
    fabRows.push([p.id.replace('gid://shopify/Product/',''), `"${p.title.replace(/"/g,'""')}"`, p.productType, p.color, `"${p.tags.slice(0,8).join('|')}"`].join(','));
  }

  fs.writeFileSync(path.join(OUT,'phase3_rename_v2.csv'), csvRows.join('\n'));
  fs.writeFileSync(path.join(OUT,'phase3_rename_v2.json'), JSON.stringify(jsonRows, null, 2));
  fs.writeFileSync(path.join(OUT,'phase3_pattern_cities_v2.json'), JSON.stringify(cityAssignment, null, 2));
  fs.writeFileSync(path.join(OUT,'phase3_fabrics_review.csv'), fabRows.join('\n'));

  // Dupe check
  const titleCount = {};
  for (const r of jsonRows) titleCount[r.new_title] = (titleCount[r.new_title]||0)+1;
  const stillDup = Object.entries(titleCount).filter(([,c])=>c>1);

  console.log(`\n=== PHASE 3 PREPARE v2 ===`);
  console.log(`rebrand rows: ${jsonRows.length}`);
  console.log(`patterns: ${Object.keys(cityAssignment).length}`);
  console.log(`fabric review bucket: ${fabrics.length}`);
  console.log(`remaining dup titles (should be 0): ${stillDup.length}`);
  for (const [t, c] of stillDup.slice(0,5)) console.log(`  ⚠ ${t} × ${c}`);

  console.log(`\nsample rebrand preview:`);
  // show dup-pattern group
  const shown = new Set();
  for (const r of jsonRows.slice(0, 60)) {
    if (shown.has(r.pattern_root)) continue;
    shown.add(r.pattern_root);
    const group = jsonRows.filter(x => x.pattern_root === r.pattern_root);
    if (group.length >= 2) {
      console.log(`  [${r.pattern_root}] → ${r.city}:`);
      for (const g of group) console.log(`     ${g.current_title.slice(0,55).padEnd(55)} → ${g.new_title}`);
    }
    if (shown.size >= 8) break;
  }

  console.log(`\nFiles:`);
  console.log(`  phase3_rename_v2.csv        — reviewable`);
  console.log(`  phase3_rename_v2.json       — writer input`);
  console.log(`  phase3_pattern_cities_v2.json`);
  console.log(`  phase3_fabrics_review.csv   — Fabric exclusions for separate handling`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });